@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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal in-memory DOM that satisfies the renderer's structural
|
|
3
|
+
* {@link ElementLike} / {@link DocumentLike} types, for unit tests only.
|
|
4
|
+
*
|
|
5
|
+
* It is NOT part of the published build (excluded in `tsconfig.build.json`); it
|
|
6
|
+
* lets the DOM renderer and the boot layer be exercised on Node with no DOM
|
|
7
|
+
* library and no `as` cast — the fake is *structurally* the same subset the
|
|
8
|
+
* browser's real `Document`/`Element` provide.
|
|
9
|
+
*/
|
|
10
|
+
import type { DocumentLike, ElementLike } from "./render.ts";
|
|
11
|
+
|
|
12
|
+
export class FakeElement implements ElementLike {
|
|
13
|
+
className = "";
|
|
14
|
+
textContent: string | null = null;
|
|
15
|
+
readonly tagName: string;
|
|
16
|
+
readonly attributes = new Map<string, string>();
|
|
17
|
+
readonly children: FakeElement[] = [];
|
|
18
|
+
readonly #listeners = new Map<string, Array<() => void>>();
|
|
19
|
+
|
|
20
|
+
constructor(tagName: string) {
|
|
21
|
+
this.tagName = tagName;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
setAttribute(name: string, value: string): void {
|
|
25
|
+
this.attributes.set(name, value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getAttribute(name: string): string | undefined {
|
|
29
|
+
return this.attributes.get(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
appendChild(child: ElementLike): ElementLike {
|
|
33
|
+
if (child instanceof FakeElement) this.children.push(child);
|
|
34
|
+
return child;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
replaceChildren(): void {
|
|
38
|
+
this.children.length = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
addEventListener(type: string, handler: () => void): void {
|
|
42
|
+
const list = this.#listeners.get(type) ?? [];
|
|
43
|
+
list.push(handler);
|
|
44
|
+
this.#listeners.set(type, list);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fire every listener registered for `type` (test driver for clicks). */
|
|
48
|
+
dispatch(type: string): void {
|
|
49
|
+
for (const handler of this.#listeners.get(type) ?? []) handler();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Depth-first walk of this element and its descendants. */
|
|
53
|
+
*walk(): IterableIterator<FakeElement> {
|
|
54
|
+
yield this;
|
|
55
|
+
for (const child of this.children) yield* child.walk();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every descendant (and self) whose className contains `cls`. */
|
|
59
|
+
byClass(cls: string): FakeElement[] {
|
|
60
|
+
const out: FakeElement[] = [];
|
|
61
|
+
for (const node of this.walk()) {
|
|
62
|
+
if (node.className.split(/\s+/).includes(cls)) out.push(node);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every descendant (and self) with `data-<key>` equal to `value` (any value if omitted). */
|
|
68
|
+
byData(key: string, value?: string): FakeElement[] {
|
|
69
|
+
const attr = `data-${key}`;
|
|
70
|
+
const out: FakeElement[] = [];
|
|
71
|
+
for (const node of this.walk()) {
|
|
72
|
+
const got = node.attributes.get(attr);
|
|
73
|
+
if (got !== undefined && (value === undefined || got === value)) out.push(node);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The concatenated text content of this subtree (own text then children). */
|
|
79
|
+
text(): string {
|
|
80
|
+
let out = this.textContent ?? "";
|
|
81
|
+
for (const child of this.children) out += child.text();
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class FakeDocument implements DocumentLike {
|
|
87
|
+
createElement(tagName: string): ElementLike {
|
|
88
|
+
return new FakeElement(tagName);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nanobpm/agentic-cockpit — the visibility page (the cockpit) for the Nano
|
|
3
|
+
* agentic protocol (ADR 0056, slice S8).
|
|
4
|
+
*
|
|
5
|
+
* The operator's cockpit over the one app-tier channel: a per-network
|
|
6
|
+
* demand×supply matrix, a red light per *missing agent type*, the diversity-SLO
|
|
7
|
+
* state (all from the S4 {@link cockpitView} projection of `@nanobpm/agentic-demand`),
|
|
8
|
+
* and drill-into-a-worker → a live terminal over the S5 relay
|
|
9
|
+
* ({@link TerminalSession} + {@link RelayChannelClient}) that **survives a cockpit
|
|
10
|
+
* reconnect** via resume-from-offset.
|
|
11
|
+
*
|
|
12
|
+
* The package is a framework-free, injection-based core: {@link bootCockpit}
|
|
13
|
+
* wires polling, rendering and the terminal from an injected {@link CockpitEnv},
|
|
14
|
+
* with no hard dependency on the DOM, a socket implementation, or xterm.js. Both
|
|
15
|
+
* shells under `page/` — the standalone one and the console App-View embed — call
|
|
16
|
+
* the SAME {@link bootCockpit} against the SAME {@link renderCockpit}, so the page
|
|
17
|
+
* renders identically whether embedded or standalone.
|
|
18
|
+
*
|
|
19
|
+
* The wire contract is `@nanobpm/agentic-protocol`; the report comes from
|
|
20
|
+
* `@nanobpm/agentic-demand`; the relay sub-protocol from `@nanobpm/agentic-relay`.
|
|
21
|
+
* Nothing here rides the Camunda-8 engine or its transport.
|
|
22
|
+
*/
|
|
23
|
+
export {
|
|
24
|
+
cockpitView,
|
|
25
|
+
type CockpitLight,
|
|
26
|
+
type CockpitNetworkRow,
|
|
27
|
+
type CockpitTokenRow,
|
|
28
|
+
type CockpitView,
|
|
29
|
+
type SloStatus,
|
|
30
|
+
} from "./view.ts";
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
TerminalSession,
|
|
34
|
+
type RelayInbound,
|
|
35
|
+
type RelayOutbound,
|
|
36
|
+
type RelaySend,
|
|
37
|
+
type TerminalSessionOptions,
|
|
38
|
+
type TerminalSink,
|
|
39
|
+
} from "./terminal-session.ts";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
RelayChannelClient,
|
|
43
|
+
type RawSocket,
|
|
44
|
+
type RelayChannelClientOptions,
|
|
45
|
+
type Scheduler,
|
|
46
|
+
type SocketFactory,
|
|
47
|
+
} from "./relay-client.ts";
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
renderCockpit,
|
|
51
|
+
type CockpitDom,
|
|
52
|
+
type DocumentLike,
|
|
53
|
+
type ElementLike,
|
|
54
|
+
type RenderOptions,
|
|
55
|
+
} from "./render.ts";
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
bootCockpit,
|
|
59
|
+
type CockpitEnv,
|
|
60
|
+
type CockpitHandle,
|
|
61
|
+
type CreateTerminal,
|
|
62
|
+
type TimerHandle,
|
|
63
|
+
} from "./boot.ts";
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { decodeFrame, encodeFrame, type Frame } from "../protocol/index.ts";
|
|
5
|
+
|
|
6
|
+
import { RelayChannelClient } from "./relay-client.ts";
|
|
7
|
+
import type { RawSocket } from "./relay-client.ts";
|
|
8
|
+
import type { RelayInbound } from "./terminal-session.ts";
|
|
9
|
+
|
|
10
|
+
class FakeSocket implements RawSocket {
|
|
11
|
+
readonly sent: Uint8Array[] = [];
|
|
12
|
+
closed = false;
|
|
13
|
+
#onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
14
|
+
#onOpen: (() => void) | undefined;
|
|
15
|
+
#onClose: (() => void) | undefined;
|
|
16
|
+
|
|
17
|
+
send(bytes: Uint8Array): void {
|
|
18
|
+
this.sent.push(bytes);
|
|
19
|
+
}
|
|
20
|
+
close(): void {
|
|
21
|
+
this.closed = true;
|
|
22
|
+
}
|
|
23
|
+
onMessage(listener: (bytes: Uint8Array) => void): void {
|
|
24
|
+
this.#onMessage = listener;
|
|
25
|
+
}
|
|
26
|
+
onOpen(listener: () => void): void {
|
|
27
|
+
this.#onOpen = listener;
|
|
28
|
+
}
|
|
29
|
+
onClose(listener: () => void): void {
|
|
30
|
+
this.#onClose = listener;
|
|
31
|
+
}
|
|
32
|
+
fireOpen(): void {
|
|
33
|
+
this.#onOpen?.();
|
|
34
|
+
}
|
|
35
|
+
fireClose(): void {
|
|
36
|
+
this.#onClose?.();
|
|
37
|
+
}
|
|
38
|
+
deliver(frame: Frame): void {
|
|
39
|
+
this.#onMessage?.(encodeFrame(frame));
|
|
40
|
+
}
|
|
41
|
+
lastSentFrame(): Frame {
|
|
42
|
+
const bytes = this.sent.at(-1);
|
|
43
|
+
assert.ok(bytes !== undefined, "expected a sent frame");
|
|
44
|
+
return decodeFrame(bytes);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("sendRelay encodes a relay message as a control-lane relay frame", () => {
|
|
49
|
+
const socket = new FakeSocket();
|
|
50
|
+
const client = new RelayChannelClient({ connect: () => socket, onRelay: () => {} });
|
|
51
|
+
client.open();
|
|
52
|
+
client.sendRelay({ op: "subscribe", stream: "w1", from: 3, credit: 128 });
|
|
53
|
+
|
|
54
|
+
const frame = socket.lastSentFrame();
|
|
55
|
+
assert.equal(frame.family, "relay");
|
|
56
|
+
assert.equal(frame.lane, "control");
|
|
57
|
+
assert.deepEqual(frame.payload, { op: "subscribe", stream: "w1", from: 3, credit: 128 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("inbound relay data and ack frames are routed to onRelay", () => {
|
|
61
|
+
const socket = new FakeSocket();
|
|
62
|
+
const received: RelayInbound[] = [];
|
|
63
|
+
const client = new RelayChannelClient({ connect: () => socket, onRelay: (m) => received.push(m) });
|
|
64
|
+
client.open();
|
|
65
|
+
|
|
66
|
+
socket.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "w1", offset: 7, chunk: "hi" } });
|
|
67
|
+
socket.deliver({ lane: "control", family: "relay", seq: 1, payload: { op: "subscribed", stream: "w1", gap: false, nextOffset: 8 } });
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(received, [
|
|
70
|
+
{ stream: "w1", offset: 7, chunk: "hi" },
|
|
71
|
+
{ op: "subscribed", stream: "w1", gap: false, nextOffset: 8 },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("frames of other families are ignored", () => {
|
|
76
|
+
const socket = new FakeSocket();
|
|
77
|
+
const received: RelayInbound[] = [];
|
|
78
|
+
const client = new RelayChannelClient({ connect: () => socket, onRelay: (m) => received.push(m) });
|
|
79
|
+
client.open();
|
|
80
|
+
socket.deliver({ lane: "control", family: "heartbeat", seq: 0, payload: { instance: "w1" } });
|
|
81
|
+
assert.equal(received.length, 0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a malformed relay payload reports an error and is not routed", () => {
|
|
85
|
+
const socket = new FakeSocket();
|
|
86
|
+
const received: RelayInbound[] = [];
|
|
87
|
+
const errors: unknown[] = [];
|
|
88
|
+
const client = new RelayChannelClient({
|
|
89
|
+
connect: () => socket,
|
|
90
|
+
onRelay: (m) => received.push(m),
|
|
91
|
+
onError: (e) => errors.push(e),
|
|
92
|
+
});
|
|
93
|
+
client.open();
|
|
94
|
+
socket.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "w1" } });
|
|
95
|
+
assert.equal(received.length, 0);
|
|
96
|
+
assert.equal(errors.length, 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a throwing onRelay consumer is routed to onError, not escaped to the socket handler", () => {
|
|
100
|
+
const socket = new FakeSocket();
|
|
101
|
+
const errors: unknown[] = [];
|
|
102
|
+
const boom = new RangeError("consumer blew up");
|
|
103
|
+
const client = new RelayChannelClient({
|
|
104
|
+
connect: () => socket,
|
|
105
|
+
onRelay: () => {
|
|
106
|
+
throw boom;
|
|
107
|
+
},
|
|
108
|
+
onError: (e) => errors.push(e),
|
|
109
|
+
});
|
|
110
|
+
client.open();
|
|
111
|
+
// A valid inbound frame whose consumer throws (e.g. TerminalSession.handle on
|
|
112
|
+
// an unsafe offset) must not escape #receive — it is routed to onError like
|
|
113
|
+
// decode/malformed errors, keeping the socket message handler intact.
|
|
114
|
+
assert.doesNotThrow(() =>
|
|
115
|
+
socket.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "w1", offset: 7, chunk: "hi" } }),
|
|
116
|
+
);
|
|
117
|
+
assert.deepEqual(errors, [boom]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the hub's boolean-gap subscribed ack is accepted (matches the S5 wire)", () => {
|
|
121
|
+
const socket = new FakeSocket();
|
|
122
|
+
const received: RelayInbound[] = [];
|
|
123
|
+
const errors: unknown[] = [];
|
|
124
|
+
const client = new RelayChannelClient({
|
|
125
|
+
connect: () => socket,
|
|
126
|
+
onRelay: (m) => received.push(m),
|
|
127
|
+
onError: (e) => errors.push(e),
|
|
128
|
+
});
|
|
129
|
+
client.open();
|
|
130
|
+
socket.deliver({ lane: "control", family: "relay", seq: 0, payload: { op: "subscribed", stream: "w1", gap: true, nextOffset: 3 } });
|
|
131
|
+
assert.deepEqual(received, [{ op: "subscribed", stream: "w1", gap: true, nextOffset: 3 }]);
|
|
132
|
+
assert.equal(errors.length, 0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a non-integer, negative, or unsafe offset/nextOffset is rejected as malformed", () => {
|
|
136
|
+
const socket = new FakeSocket();
|
|
137
|
+
const received: RelayInbound[] = [];
|
|
138
|
+
const errors: unknown[] = [];
|
|
139
|
+
const client = new RelayChannelClient({
|
|
140
|
+
connect: () => socket,
|
|
141
|
+
onRelay: (m) => received.push(m),
|
|
142
|
+
onError: (e) => errors.push(e),
|
|
143
|
+
});
|
|
144
|
+
client.open();
|
|
145
|
+
socket.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "w1", offset: 1.5, chunk: "x" } });
|
|
146
|
+
socket.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "w1", offset: -1, chunk: "x" } });
|
|
147
|
+
socket.deliver({ lane: "control", family: "relay", seq: 2, payload: { op: "subscribed", stream: "w1", gap: false, nextOffset: -1 } });
|
|
148
|
+
// Unsafe integers (beyond Number.MAX_SAFE_INTEGER) round-trip through JSON with
|
|
149
|
+
// silent precision loss, so they must be rejected like fractional/negative ones.
|
|
150
|
+
socket.deliver({ lane: "bulk", family: "relay", seq: 3, payload: { stream: "w1", offset: Number.MAX_SAFE_INTEGER + 1, chunk: "x" } });
|
|
151
|
+
socket.deliver({ lane: "control", family: "relay", seq: 4, payload: { op: "subscribed", stream: "w1", gap: false, nextOffset: Number.MAX_SAFE_INTEGER + 1 } });
|
|
152
|
+
assert.equal(received.length, 0);
|
|
153
|
+
assert.equal(errors.length, 5);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a payload with an unknown op is rejected, even if it also carries a data shape", () => {
|
|
157
|
+
const socket = new FakeSocket();
|
|
158
|
+
const received: RelayInbound[] = [];
|
|
159
|
+
const errors: unknown[] = [];
|
|
160
|
+
const client = new RelayChannelClient({
|
|
161
|
+
connect: () => socket,
|
|
162
|
+
onRelay: (m) => received.push(m),
|
|
163
|
+
onError: (e) => errors.push(e),
|
|
164
|
+
});
|
|
165
|
+
client.open();
|
|
166
|
+
// An op-message with an unrecognised op must never fall through to the data-chunk
|
|
167
|
+
// shape check, even when it happens to also carry { stream, offset, chunk }.
|
|
168
|
+
socket.deliver({
|
|
169
|
+
lane: "bulk",
|
|
170
|
+
family: "relay",
|
|
171
|
+
seq: 0,
|
|
172
|
+
payload: { op: "evil", stream: "w1", offset: 0, chunk: "x" },
|
|
173
|
+
});
|
|
174
|
+
assert.equal(received.length, 0);
|
|
175
|
+
assert.equal(errors.length, 1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("onOpen fires on every (re)connect so the session re-attaches and resumes", () => {
|
|
179
|
+
const sockets: FakeSocket[] = [];
|
|
180
|
+
let pending: (() => void) | undefined;
|
|
181
|
+
const opens: number[] = [];
|
|
182
|
+
const client = new RelayChannelClient({
|
|
183
|
+
connect: () => {
|
|
184
|
+
const s = new FakeSocket();
|
|
185
|
+
sockets.push(s);
|
|
186
|
+
return s;
|
|
187
|
+
},
|
|
188
|
+
onRelay: () => {},
|
|
189
|
+
onOpen: () => opens.push(sockets.length),
|
|
190
|
+
schedule: (run) => {
|
|
191
|
+
pending = run;
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
client.open();
|
|
196
|
+
sockets[0]?.fireOpen();
|
|
197
|
+
assert.deepEqual(opens, [1]); // first connect
|
|
198
|
+
|
|
199
|
+
// The socket drops → the client schedules a reconnect.
|
|
200
|
+
sockets[0]?.fireClose();
|
|
201
|
+
assert.equal(sockets.length, 1, "no new socket until the scheduler runs");
|
|
202
|
+
assert.ok(pending !== undefined, "a reconnect was scheduled");
|
|
203
|
+
|
|
204
|
+
pending?.(); // run the scheduled reconnect
|
|
205
|
+
assert.equal(sockets.length, 2, "a fresh socket was opened");
|
|
206
|
+
sockets[1]?.fireOpen();
|
|
207
|
+
assert.deepEqual(opens, [1, 2]); // reconnect fires onOpen again → resume
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("close() tears down the socket and suppresses reconnect", () => {
|
|
211
|
+
const sockets: FakeSocket[] = [];
|
|
212
|
+
let pending: (() => void) | undefined;
|
|
213
|
+
const client = new RelayChannelClient({
|
|
214
|
+
connect: () => {
|
|
215
|
+
const s = new FakeSocket();
|
|
216
|
+
sockets.push(s);
|
|
217
|
+
return s;
|
|
218
|
+
},
|
|
219
|
+
onRelay: () => {},
|
|
220
|
+
schedule: (run) => {
|
|
221
|
+
pending = run;
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
client.open();
|
|
225
|
+
client.close();
|
|
226
|
+
assert.equal(sockets[0]?.closed, true);
|
|
227
|
+
assert.equal(client.isClosed, true);
|
|
228
|
+
|
|
229
|
+
// A close event after an explicit close must not schedule a reconnect.
|
|
230
|
+
sockets[0]?.fireClose();
|
|
231
|
+
assert.equal(pending, undefined);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("autoReconnect:false does not reconnect on drop", () => {
|
|
235
|
+
const sockets: FakeSocket[] = [];
|
|
236
|
+
let scheduled = false;
|
|
237
|
+
const client = new RelayChannelClient({
|
|
238
|
+
connect: () => {
|
|
239
|
+
const s = new FakeSocket();
|
|
240
|
+
sockets.push(s);
|
|
241
|
+
return s;
|
|
242
|
+
},
|
|
243
|
+
onRelay: () => {},
|
|
244
|
+
autoReconnect: false,
|
|
245
|
+
schedule: () => {
|
|
246
|
+
scheduled = true;
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
client.open();
|
|
250
|
+
sockets[0]?.fireClose();
|
|
251
|
+
assert.equal(scheduled, false);
|
|
252
|
+
assert.equal(sockets.length, 1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a synchronous connect() failure is routed to onError and reschedules a retry", () => {
|
|
256
|
+
const boom = new Error("WebSocket construction failed");
|
|
257
|
+
let attempts = 0;
|
|
258
|
+
const succeedOn = 2;
|
|
259
|
+
const sockets: FakeSocket[] = [];
|
|
260
|
+
let pending: (() => void) | undefined;
|
|
261
|
+
const errors: unknown[] = [];
|
|
262
|
+
const client = new RelayChannelClient({
|
|
263
|
+
connect: () => {
|
|
264
|
+
attempts += 1;
|
|
265
|
+
if (attempts < succeedOn) throw boom;
|
|
266
|
+
const s = new FakeSocket();
|
|
267
|
+
sockets.push(s);
|
|
268
|
+
return s;
|
|
269
|
+
},
|
|
270
|
+
onRelay: () => {},
|
|
271
|
+
onError: (e) => errors.push(e),
|
|
272
|
+
schedule: (run) => {
|
|
273
|
+
pending = run;
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// The first connect throws synchronously — open() must not escape the throw.
|
|
278
|
+
client.open();
|
|
279
|
+
assert.deepEqual(errors, [boom], "the synchronous connect failure reached onError");
|
|
280
|
+
assert.equal(sockets.length, 0, "no socket was wired from a failed connect");
|
|
281
|
+
assert.ok(pending !== undefined, "a retry was scheduled after the connect failure");
|
|
282
|
+
|
|
283
|
+
// The scheduled retry succeeds → the reconnect loop survived the failure.
|
|
284
|
+
pending?.();
|
|
285
|
+
assert.equal(sockets.length, 1, "the scheduled retry opened a fresh socket");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a synchronous connect() failure with autoReconnect:false does not reschedule", () => {
|
|
289
|
+
const boom = new Error("boom");
|
|
290
|
+
let scheduled = false;
|
|
291
|
+
const errors: unknown[] = [];
|
|
292
|
+
const client = new RelayChannelClient({
|
|
293
|
+
connect: () => {
|
|
294
|
+
throw boom;
|
|
295
|
+
},
|
|
296
|
+
onRelay: () => {},
|
|
297
|
+
onError: (e) => errors.push(e),
|
|
298
|
+
autoReconnect: false,
|
|
299
|
+
schedule: () => {
|
|
300
|
+
scheduled = true;
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
client.open();
|
|
305
|
+
assert.deepEqual(errors, [boom], "the failure was surfaced to onError");
|
|
306
|
+
assert.equal(scheduled, false, "no retry scheduled when autoReconnect is disabled");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a throwing onOpen consumer is routed to onError, not escaped to the socket handler", () => {
|
|
310
|
+
const socket = new FakeSocket();
|
|
311
|
+
const boom = new Error("onOpen handler threw");
|
|
312
|
+
const errors: unknown[] = [];
|
|
313
|
+
const client = new RelayChannelClient({
|
|
314
|
+
connect: () => socket,
|
|
315
|
+
onRelay: () => {},
|
|
316
|
+
onOpen: () => {
|
|
317
|
+
throw boom;
|
|
318
|
+
},
|
|
319
|
+
onError: (e) => errors.push(e),
|
|
320
|
+
});
|
|
321
|
+
client.open();
|
|
322
|
+
|
|
323
|
+
// A consumer onOpen that throws (e.g. a resume/re-subscribe handler) must not
|
|
324
|
+
// escape the socket open callback — it is routed to onError like #receive does.
|
|
325
|
+
assert.doesNotThrow(() => socket.fireOpen());
|
|
326
|
+
assert.deepEqual(errors, [boom], "the throwing onOpen was surfaced to onError");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("a throwing onClose consumer is routed to onError and still schedules reconnect", () => {
|
|
330
|
+
const sockets: FakeSocket[] = [];
|
|
331
|
+
const boom = new Error("onClose handler threw");
|
|
332
|
+
const errors: unknown[] = [];
|
|
333
|
+
let pending: (() => void) | undefined;
|
|
334
|
+
const client = new RelayChannelClient({
|
|
335
|
+
connect: () => {
|
|
336
|
+
const s = new FakeSocket();
|
|
337
|
+
sockets.push(s);
|
|
338
|
+
return s;
|
|
339
|
+
},
|
|
340
|
+
onRelay: () => {},
|
|
341
|
+
onClose: () => {
|
|
342
|
+
throw boom;
|
|
343
|
+
},
|
|
344
|
+
onError: (e) => errors.push(e),
|
|
345
|
+
schedule: (run) => {
|
|
346
|
+
pending = run;
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
client.open();
|
|
350
|
+
|
|
351
|
+
// A consumer onClose that throws must not skip the reconnect scheduling below
|
|
352
|
+
// it — the "survives reconnect" invariant must hold regardless of the handler.
|
|
353
|
+
assert.doesNotThrow(() => sockets[0]?.fireClose());
|
|
354
|
+
assert.deepEqual(errors, [boom], "the throwing onClose was surfaced to onError");
|
|
355
|
+
assert.ok(pending !== undefined, "a reconnect was still scheduled despite the onClose throw");
|
|
356
|
+
|
|
357
|
+
pending?.();
|
|
358
|
+
assert.equal(sockets.length, 2, "the scheduled reconnect opened a fresh socket");
|
|
359
|
+
});
|