@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,374 @@
|
|
|
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
|
+
import type { DemandSupplyReport } from "../demand/index.ts";
|
|
6
|
+
|
|
7
|
+
import { bootCockpit, type CockpitEnv } from "./boot.ts";
|
|
8
|
+
import { FakeDocument, FakeElement } from "./fake-dom.ts";
|
|
9
|
+
import type { RawSocket } from "./relay-client.ts";
|
|
10
|
+
|
|
11
|
+
class FakeSocket implements RawSocket {
|
|
12
|
+
readonly sent: Uint8Array[] = [];
|
|
13
|
+
closed = false;
|
|
14
|
+
#onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
15
|
+
#onOpen: (() => void) | undefined;
|
|
16
|
+
#onClose: (() => void) | undefined;
|
|
17
|
+
send(bytes: Uint8Array): void {
|
|
18
|
+
this.sent.push(bytes);
|
|
19
|
+
}
|
|
20
|
+
close(): void {
|
|
21
|
+
this.closed = true;
|
|
22
|
+
}
|
|
23
|
+
onMessage(l: (bytes: Uint8Array) => void): void {
|
|
24
|
+
this.#onMessage = l;
|
|
25
|
+
}
|
|
26
|
+
onOpen(l: () => void): void {
|
|
27
|
+
this.#onOpen = l;
|
|
28
|
+
}
|
|
29
|
+
onClose(l: () => void): void {
|
|
30
|
+
this.#onClose = l;
|
|
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
|
+
subscribeFrames(): Frame[] {
|
|
42
|
+
return this.sent.map(decodeFrame).filter((f) => f.family === "relay");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
47
|
+
|
|
48
|
+
function reportWith(networks: DemandSupplyReport["networks"], missing: string[] = []): DemandSupplyReport {
|
|
49
|
+
return {
|
|
50
|
+
networks,
|
|
51
|
+
missing,
|
|
52
|
+
diversity: { status: "green", roles: [] },
|
|
53
|
+
status: missing.length > 0 ? "red" : "green",
|
|
54
|
+
nonAgentic: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const served = reportWith([
|
|
59
|
+
{ network: "ci", tokens: [{ token: "ci.build", supply: 1, instances: ["ci-a"], satisfied: true }], missing: [] },
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
interface Rig {
|
|
63
|
+
readonly env: CockpitEnv;
|
|
64
|
+
readonly host: FakeElement;
|
|
65
|
+
readonly sockets: FakeSocket[];
|
|
66
|
+
readonly terminalWrites: string[];
|
|
67
|
+
terminalMounts: number;
|
|
68
|
+
terminalDisposes: number;
|
|
69
|
+
readonly timers: Array<{ run: () => void; ms: number }>;
|
|
70
|
+
reconnect: (() => void) | undefined;
|
|
71
|
+
report: DemandSupplyReport;
|
|
72
|
+
errors: unknown[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rig(): Rig {
|
|
76
|
+
const host = new FakeElement("body");
|
|
77
|
+
const sockets: FakeSocket[] = [];
|
|
78
|
+
const terminalWrites: string[] = [];
|
|
79
|
+
const timers: Array<{ run: () => void; ms: number }> = [];
|
|
80
|
+
const state: Rig = {
|
|
81
|
+
host,
|
|
82
|
+
sockets,
|
|
83
|
+
terminalWrites,
|
|
84
|
+
terminalMounts: 0,
|
|
85
|
+
terminalDisposes: 0,
|
|
86
|
+
timers,
|
|
87
|
+
reconnect: undefined,
|
|
88
|
+
report: served,
|
|
89
|
+
errors: [],
|
|
90
|
+
env: {
|
|
91
|
+
host,
|
|
92
|
+
doc: new FakeDocument(),
|
|
93
|
+
fetchReport: () => Promise.resolve(state.report),
|
|
94
|
+
connectRelay: () => {
|
|
95
|
+
const s = new FakeSocket();
|
|
96
|
+
sockets.push(s);
|
|
97
|
+
return s;
|
|
98
|
+
},
|
|
99
|
+
createTerminal: (terminalHost) => {
|
|
100
|
+
state.terminalMounts += 1;
|
|
101
|
+
terminalHost.appendChild(new FakeElement("pre"));
|
|
102
|
+
return {
|
|
103
|
+
write: (chunk) => terminalWrites.push(chunk),
|
|
104
|
+
dispose: () => {
|
|
105
|
+
state.terminalDisposes += 1;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
schedule: (run) => {
|
|
110
|
+
state.reconnect = run;
|
|
111
|
+
},
|
|
112
|
+
setTimer: (run, ms) => {
|
|
113
|
+
timers.push({ run, ms });
|
|
114
|
+
return timers.length - 1;
|
|
115
|
+
},
|
|
116
|
+
clearTimer: () => {},
|
|
117
|
+
onError: (err) => state.errors.push(err),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test("refresh renders the live demand×supply matrix into the host", async () => {
|
|
124
|
+
const r = rig();
|
|
125
|
+
const cockpit = bootCockpit(r.env);
|
|
126
|
+
await cockpit.refresh();
|
|
127
|
+
assert.equal(r.host.byData("network", "ci").length, 1);
|
|
128
|
+
assert.equal(r.host.byClass("cockpit-token").length, 1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a later poll reflects a changed report (a newly missing agent type lights red)", async () => {
|
|
132
|
+
const r = rig();
|
|
133
|
+
const cockpit = bootCockpit(r.env);
|
|
134
|
+
await cockpit.refresh();
|
|
135
|
+
assert.equal(r.host.byClass("cockpit-network")[0]?.getAttribute("data-status"), "green");
|
|
136
|
+
|
|
137
|
+
r.report = reportWith(
|
|
138
|
+
[
|
|
139
|
+
{
|
|
140
|
+
network: "ci",
|
|
141
|
+
tokens: [{ token: "ci.build", supply: 0, instances: [], satisfied: false }],
|
|
142
|
+
missing: ["ci.build"],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
["ci.build"],
|
|
146
|
+
);
|
|
147
|
+
await cockpit.refresh();
|
|
148
|
+
assert.equal(r.host.byData("network", "ci")[0]?.getAttribute("data-status"), "red");
|
|
149
|
+
const missing = r.host.byClass("cockpit-light").filter((l) => l.getAttribute("data-light-id")?.startsWith("missing:"));
|
|
150
|
+
assert.equal(missing.length, 1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("drilling into a worker subscribes its relay stream on connect", async () => {
|
|
154
|
+
const r = rig();
|
|
155
|
+
const cockpit = bootCockpit(r.env);
|
|
156
|
+
await cockpit.refresh();
|
|
157
|
+
cockpit.drill("ci-a");
|
|
158
|
+
assert.equal(cockpit.currentStream, "ci-a");
|
|
159
|
+
assert.equal(r.sockets.length, 1);
|
|
160
|
+
|
|
161
|
+
r.sockets[0]?.fireOpen(); // (re)connect → session.attach()
|
|
162
|
+
const subs = r.sockets[0]?.subscribeFrames() ?? [];
|
|
163
|
+
assert.equal(subs.length, 1);
|
|
164
|
+
assert.deepEqual(subs[0]?.payload, { op: "subscribe", stream: "ci-a", from: 0, credit: 1024 });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("relay output is written to the drilled worker's terminal", async () => {
|
|
168
|
+
const r = rig();
|
|
169
|
+
const cockpit = bootCockpit(r.env);
|
|
170
|
+
await cockpit.refresh();
|
|
171
|
+
cockpit.drill("ci-a");
|
|
172
|
+
r.sockets[0]?.fireOpen();
|
|
173
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "boot\n" } });
|
|
174
|
+
assert.deepEqual(r.terminalWrites, ["boot\n"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("the terminal survives a matrix refresh — it is not re-mounted and keeps streaming", async () => {
|
|
178
|
+
const r = rig();
|
|
179
|
+
const cockpit = bootCockpit(r.env);
|
|
180
|
+
await cockpit.refresh();
|
|
181
|
+
cockpit.drill("ci-a");
|
|
182
|
+
r.sockets[0]?.fireOpen();
|
|
183
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "a" } });
|
|
184
|
+
|
|
185
|
+
await cockpit.refresh(); // a poll re-renders the matrix
|
|
186
|
+
assert.equal(r.terminalMounts, 1, "the terminal was not re-mounted by the refresh");
|
|
187
|
+
|
|
188
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "ci-a", offset: 1, chunk: "b" } });
|
|
189
|
+
assert.deepEqual(r.terminalWrites, ["a", "b"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("the terminal survives a cockpit reconnect — resume-from-offset, no loss, no dup", async () => {
|
|
193
|
+
const r = rig();
|
|
194
|
+
const cockpit = bootCockpit(r.env);
|
|
195
|
+
await cockpit.refresh();
|
|
196
|
+
cockpit.drill("ci-a");
|
|
197
|
+
|
|
198
|
+
const s1 = r.sockets[0];
|
|
199
|
+
s1?.fireOpen();
|
|
200
|
+
s1?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "a" } });
|
|
201
|
+
s1?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "ci-a", offset: 1, chunk: "b" } });
|
|
202
|
+
|
|
203
|
+
// The cockpit's socket drops; the client schedules a reconnect.
|
|
204
|
+
s1?.fireClose();
|
|
205
|
+
assert.ok(r.reconnect !== undefined, "a reconnect was scheduled");
|
|
206
|
+
r.reconnect?.();
|
|
207
|
+
assert.equal(r.sockets.length, 2, "a fresh socket was opened");
|
|
208
|
+
|
|
209
|
+
const s2 = r.sockets[1];
|
|
210
|
+
s2?.fireOpen(); // re-attach → resume from offset 2
|
|
211
|
+
const subs = s2?.subscribeFrames() ?? [];
|
|
212
|
+
assert.deepEqual(subs.at(-1)?.payload, { op: "subscribe", stream: "ci-a", from: 2, credit: 1024 });
|
|
213
|
+
|
|
214
|
+
// The hub replays the retained tail (re-sends 1) then continues.
|
|
215
|
+
s2?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 1, chunk: "b" } });
|
|
216
|
+
s2?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "ci-a", offset: 2, chunk: "c" } });
|
|
217
|
+
assert.deepEqual(r.terminalWrites, ["a", "b", "c"], "no lost and no duplicated output across reconnect");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("drilling the same stream twice does not re-open; a different stream switches", async () => {
|
|
221
|
+
const r = rig();
|
|
222
|
+
const cockpit = bootCockpit(r.env);
|
|
223
|
+
await cockpit.refresh();
|
|
224
|
+
cockpit.drill("ci-a");
|
|
225
|
+
cockpit.drill("ci-a");
|
|
226
|
+
assert.equal(r.sockets.length, 1);
|
|
227
|
+
assert.equal(r.terminalMounts, 1);
|
|
228
|
+
|
|
229
|
+
cockpit.drill("ci-b");
|
|
230
|
+
assert.equal(r.sockets[0]?.closed, true, "the previous connection was closed");
|
|
231
|
+
assert.equal(r.sockets.length, 2);
|
|
232
|
+
assert.equal(r.terminalMounts, 2);
|
|
233
|
+
assert.equal(cockpit.currentStream, "ci-b");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a synchronous failure building a new drill is reported and leaves no stale current stream", async () => {
|
|
237
|
+
const r = rig();
|
|
238
|
+
let mounts = 0;
|
|
239
|
+
const flaky: CockpitEnv = {
|
|
240
|
+
...r.env,
|
|
241
|
+
createTerminal: (terminalHost) => {
|
|
242
|
+
mounts += 1;
|
|
243
|
+
// The second drill's terminal fails to build.
|
|
244
|
+
if (mounts === 2) throw new Error("terminal boom");
|
|
245
|
+
return r.env.createTerminal(terminalHost);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
const cockpit = bootCockpit(flaky);
|
|
249
|
+
await cockpit.refresh();
|
|
250
|
+
|
|
251
|
+
cockpit.drill("ci-a");
|
|
252
|
+
assert.equal(cockpit.currentStream, "ci-a");
|
|
253
|
+
assert.equal(r.sockets.length, 1);
|
|
254
|
+
|
|
255
|
+
// Switching streams: building the new drill throws synchronously. The prior
|
|
256
|
+
// client is still closed, the error is routed to onError, and currentStream
|
|
257
|
+
// reports no live drill — never the stale, already-closed "ci-a".
|
|
258
|
+
cockpit.drill("ci-b");
|
|
259
|
+
assert.equal(r.sockets[0]?.closed, true, "the previous connection was closed");
|
|
260
|
+
assert.equal(r.sockets.length, 1, "no socket opened for the failed drill");
|
|
261
|
+
assert.equal(cockpit.currentStream, undefined, "no stale current stream after a failed drill");
|
|
262
|
+
assert.match(String(r.errors.at(-1)), /terminal boom/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("an invalid credit fails the drill into onError instead of escaping the handler", async () => {
|
|
266
|
+
const r = rig();
|
|
267
|
+
const badCredit: CockpitEnv = { ...r.env, credit: 0 };
|
|
268
|
+
const cockpit = bootCockpit(badCredit);
|
|
269
|
+
await cockpit.refresh();
|
|
270
|
+
|
|
271
|
+
// TerminalSession construction rejects a non-positive credit; the throw is
|
|
272
|
+
// caught, routed to onError, and no drill/socket is left dangling.
|
|
273
|
+
cockpit.drill("ci-a");
|
|
274
|
+
assert.equal(cockpit.currentStream, undefined);
|
|
275
|
+
assert.equal(r.sockets.length, 0, "connect never ran, so no socket was opened");
|
|
276
|
+
assert.match(String(r.errors.at(-1)), /credit must be a positive safe integer/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("start runs a pass and self-schedules the next; stop halts it", async () => {
|
|
280
|
+
const r = rig();
|
|
281
|
+
const cockpit = bootCockpit(r.env);
|
|
282
|
+
cockpit.start();
|
|
283
|
+
await flush();
|
|
284
|
+
assert.equal(r.host.byData("network", "ci").length, 1, "first pass rendered");
|
|
285
|
+
assert.equal(r.timers.length, 1, "next pass scheduled once");
|
|
286
|
+
|
|
287
|
+
r.timers[0]?.run(); // fire the scheduled tick
|
|
288
|
+
await flush();
|
|
289
|
+
assert.equal(r.timers.length, 2, "a subsequent pass was scheduled");
|
|
290
|
+
|
|
291
|
+
cockpit.stop();
|
|
292
|
+
const before = r.timers.length;
|
|
293
|
+
await flush();
|
|
294
|
+
assert.equal(r.timers.length, before, "no further passes after stop");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a fetch error is reported and does not wedge the poll", async () => {
|
|
298
|
+
const r = rig();
|
|
299
|
+
const failing: CockpitEnv = { ...r.env, fetchReport: () => Promise.reject(new Error("boom")) };
|
|
300
|
+
const cockpit = bootCockpit(failing);
|
|
301
|
+
await cockpit.refresh();
|
|
302
|
+
assert.equal(r.errors.length, 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("dispose stops polling and closes the terminal connection", async () => {
|
|
306
|
+
const r = rig();
|
|
307
|
+
const cockpit = bootCockpit(r.env);
|
|
308
|
+
await cockpit.refresh();
|
|
309
|
+
cockpit.drill("ci-a");
|
|
310
|
+
cockpit.dispose();
|
|
311
|
+
assert.equal(r.sockets[0]?.closed, true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("injecting setTimer without clearTimer (or vice versa) fails fast", () => {
|
|
315
|
+
const base = rig().env;
|
|
316
|
+
const noop = () => 0;
|
|
317
|
+
assert.throws(
|
|
318
|
+
() => bootCockpit({ ...base, setTimer: noop, clearTimer: undefined }),
|
|
319
|
+
/setTimer and clearTimer must be provided together/,
|
|
320
|
+
);
|
|
321
|
+
assert.throws(
|
|
322
|
+
() => bootCockpit({ ...base, setTimer: undefined, clearTimer: () => {} }),
|
|
323
|
+
/setTimer and clearTimer must be provided together/,
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("an invalid refreshMs fails fast at construction", () => {
|
|
328
|
+
const base = rig().env;
|
|
329
|
+
for (const refreshMs of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN]) {
|
|
330
|
+
assert.throws(
|
|
331
|
+
() => bootCockpit({ ...base, refreshMs }),
|
|
332
|
+
/refreshMs must be a positive safe integer/,
|
|
333
|
+
`refreshMs ${refreshMs} should be rejected`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("switching streams disposes the prior terminal, and dispose tears the last one down", async () => {
|
|
339
|
+
const r = rig();
|
|
340
|
+
const cockpit = bootCockpit(r.env);
|
|
341
|
+
await cockpit.refresh();
|
|
342
|
+
|
|
343
|
+
cockpit.drill("ci-a");
|
|
344
|
+
assert.equal(r.terminalMounts, 1);
|
|
345
|
+
assert.equal(r.terminalDisposes, 0, "nothing to dispose on the first drill");
|
|
346
|
+
|
|
347
|
+
// Switching streams must tear down the previous xterm instance before mounting
|
|
348
|
+
// the next — replaceChildren only drops the DOM node, so without an explicit
|
|
349
|
+
// dispose the widget + its listeners would leak on every drill.
|
|
350
|
+
cockpit.drill("ci-b");
|
|
351
|
+
assert.equal(r.terminalMounts, 2);
|
|
352
|
+
assert.equal(r.terminalDisposes, 1, "prior terminal disposed on stream switch");
|
|
353
|
+
|
|
354
|
+
cockpit.dispose();
|
|
355
|
+
assert.equal(r.terminalDisposes, 2, "the live terminal is disposed on dispose()");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a stop→start race does not leave two overlapping poll chains scheduling", async () => {
|
|
359
|
+
const r = rig();
|
|
360
|
+
const cockpit = bootCockpit(r.env);
|
|
361
|
+
|
|
362
|
+
// First start's tick has an in-flight refresh (suspended at the fetch await),
|
|
363
|
+
// whose finally has not run yet. Stopping then restarting before it settles
|
|
364
|
+
// must invalidate that stale tick so only the new start's chain schedules —
|
|
365
|
+
// otherwise the stale finally re-arms a second, overlapping timer.
|
|
366
|
+
cockpit.start();
|
|
367
|
+
cockpit.stop();
|
|
368
|
+
cockpit.start();
|
|
369
|
+
await flush();
|
|
370
|
+
|
|
371
|
+
assert.equal(r.timers.length, 1, "exactly one poll chain scheduled after the race");
|
|
372
|
+
|
|
373
|
+
cockpit.stop();
|
|
374
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cockpit boot/orchestration layer — S8.
|
|
3
|
+
*
|
|
4
|
+
* Wires the whole page together from injected capabilities, with **no** direct
|
|
5
|
+
* dependency on the browser, a socket implementation, or xterm.js — everything is
|
|
6
|
+
* passed in ({@link CockpitEnv}). That is what makes the page render *identically*
|
|
7
|
+
* embedded (console App View) and standalone (the two shells differ only in the
|
|
8
|
+
* `host` element and the concrete capabilities they inject), and what makes the
|
|
9
|
+
* live-refresh + drill-in + resume-on-reconnect path unit-testable on Node.
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities:
|
|
12
|
+
* - a **self-scheduling** poll of the S4 demand×supply report that re-renders the
|
|
13
|
+
* matrix/lights each pass (a slow fetch can't overlap the next — mirrors the
|
|
14
|
+
* nano-workforce poller discipline);
|
|
15
|
+
* - **drill-into-a-worker**: open a {@link RelayChannelClient} + {@link TerminalSession}
|
|
16
|
+
* for the selected stream, mount its output into a *persistent* terminal region
|
|
17
|
+
* (so a matrix refresh never wipes it), and re-attach on every reconnect so the
|
|
18
|
+
* terminal **survives a cockpit reconnect** via resume-from-offset.
|
|
19
|
+
*/
|
|
20
|
+
import type { DemandSupplyReport } from "../demand/index.ts";
|
|
21
|
+
import { isPosInt } from "../relay/index.ts";
|
|
22
|
+
import { RelayChannelClient, type Scheduler, type SocketFactory } from "./relay-client.ts";
|
|
23
|
+
import { type DocumentLike, type ElementLike, renderCockpit } from "./render.ts";
|
|
24
|
+
import { TerminalSession, type TerminalSink } from "./terminal-session.ts";
|
|
25
|
+
import { cockpitView } from "./view.ts";
|
|
26
|
+
|
|
27
|
+
/** Mounts a terminal into `host` and returns the sink relay output is written to. */
|
|
28
|
+
export type CreateTerminal = (host: ElementLike) => TerminalSink;
|
|
29
|
+
|
|
30
|
+
/** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */
|
|
31
|
+
export type TimerHandle = unknown;
|
|
32
|
+
|
|
33
|
+
export interface CockpitEnv {
|
|
34
|
+
/** The element the cockpit renders into (standalone: `document.body`; embedded: the App View host). */
|
|
35
|
+
readonly host: ElementLike;
|
|
36
|
+
/** The document the renderer creates elements from. */
|
|
37
|
+
readonly doc: DocumentLike;
|
|
38
|
+
/** Fetches the latest S4 demand×supply report (e.g. over HTTP or the channel). */
|
|
39
|
+
readonly fetchReport: () => Promise<DemandSupplyReport>;
|
|
40
|
+
/** Opens a socket to the app relay channel (one per drill-in connection). */
|
|
41
|
+
readonly connectRelay: SocketFactory;
|
|
42
|
+
/** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
|
|
43
|
+
readonly createTerminal: CreateTerminal;
|
|
44
|
+
/** Reconnect scheduler for the relay client. Default `setTimeout(run, 0)`. */
|
|
45
|
+
readonly schedule?: Scheduler;
|
|
46
|
+
/** Poll scheduler. Default `setTimeout`. Injected so tests drive it by hand. Must be paired with {@link clearTimer}. */
|
|
47
|
+
readonly setTimer?: (run: () => void, ms: number) => TimerHandle;
|
|
48
|
+
/** Cancels a poll timer. Default `clearTimeout`. Must be paired with {@link setTimer}. */
|
|
49
|
+
readonly clearTimer?: (handle: TimerHandle) => void;
|
|
50
|
+
/** Poll interval in ms. Default 2000. */
|
|
51
|
+
readonly refreshMs?: number;
|
|
52
|
+
/** Bulk credit granted per terminal (re)subscribe. Default 1024. */
|
|
53
|
+
readonly credit?: number;
|
|
54
|
+
/** Notified of a fetch/render/relay error (the poll keeps going). */
|
|
55
|
+
readonly onError?: (err: unknown) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The running cockpit; dispose to stop polling and tear down the terminal. */
|
|
59
|
+
export interface CockpitHandle {
|
|
60
|
+
/** Run one fetch→render pass now (also the poll body). Resolves when rendered. */
|
|
61
|
+
refresh(): Promise<void>;
|
|
62
|
+
/** Start the self-scheduling poll loop (runs one pass immediately). */
|
|
63
|
+
start(): void;
|
|
64
|
+
/** Stop the poll loop (leaves the last render in place). */
|
|
65
|
+
stop(): void;
|
|
66
|
+
/** Drill into a worker's relay stream, opening a resumable live terminal. */
|
|
67
|
+
drill(stream: string): void;
|
|
68
|
+
/** The stream currently drilled into, if any. */
|
|
69
|
+
readonly currentStream: string | undefined;
|
|
70
|
+
/** Stop everything and release the terminal connection. */
|
|
71
|
+
dispose(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DEFAULT_REFRESH_MS = 2000;
|
|
75
|
+
|
|
76
|
+
interface Drill {
|
|
77
|
+
readonly stream: string;
|
|
78
|
+
readonly client: RelayChannelClient;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class Cockpit implements CockpitHandle {
|
|
82
|
+
readonly #env: CockpitEnv;
|
|
83
|
+
readonly #matrixRegion: ElementLike;
|
|
84
|
+
readonly #terminalHost: ElementLike;
|
|
85
|
+
readonly #refreshMs: number;
|
|
86
|
+
readonly #setTimer: (run: () => void, ms: number) => TimerHandle;
|
|
87
|
+
readonly #clearTimer: (handle: TimerHandle) => void;
|
|
88
|
+
readonly #timeouts = new Map<number, ReturnType<typeof setTimeout>>();
|
|
89
|
+
#nextTimerId = 0;
|
|
90
|
+
#timer: TimerHandle | undefined;
|
|
91
|
+
#running = false;
|
|
92
|
+
#disposed = false;
|
|
93
|
+
#drill: Drill | undefined;
|
|
94
|
+
// The currently mounted terminal, tracked so switching streams (and dispose)
|
|
95
|
+
// tears down the prior xterm instance instead of leaking it + its listeners.
|
|
96
|
+
#terminal: TerminalSink | undefined;
|
|
97
|
+
// Bumped by every start()/stop() so an in-flight #tick() from a previous
|
|
98
|
+
// start cycle can't reschedule after a stop→start race and leave two
|
|
99
|
+
// overlapping poll chains running against the same cockpit.
|
|
100
|
+
#generation = 0;
|
|
101
|
+
|
|
102
|
+
constructor(env: CockpitEnv) {
|
|
103
|
+
this.#env = env;
|
|
104
|
+
this.#refreshMs = env.refreshMs ?? DEFAULT_REFRESH_MS;
|
|
105
|
+
// refreshMs feeds setTimeout as a poll delay. A negative/NaN/fractional/unsafe
|
|
106
|
+
// value silently collapses to a ~0ms delay, turning the poll into a hot loop
|
|
107
|
+
// that hammers the demand endpoint and burns CPU. Require a positive safe
|
|
108
|
+
// integer up-front so a bad env option fails loudly instead.
|
|
109
|
+
if (!isPosInt(this.#refreshMs)) {
|
|
110
|
+
throw new RangeError(`CockpitEnv.refreshMs must be a positive safe integer, got ${this.#refreshMs}`);
|
|
111
|
+
}
|
|
112
|
+
// setTimer/clearTimer are a matched pair: a caller-supplied setTimer returns
|
|
113
|
+
// opaque handles the default clearTimer (which only understands the internal
|
|
114
|
+
// numeric-handle Map) cannot cancel, leaving an un-stoppable poll loop. Fail
|
|
115
|
+
// fast rather than silently accept one without the other.
|
|
116
|
+
if ((env.setTimer === undefined) !== (env.clearTimer === undefined)) {
|
|
117
|
+
throw new Error("CockpitEnv.setTimer and clearTimer must be provided together (or neither)");
|
|
118
|
+
}
|
|
119
|
+
// Default timer path uses numeric handles backed by a Map, so no unchecked
|
|
120
|
+
// cast is needed to feed an opaque handle back to clearTimeout (`as` is banned).
|
|
121
|
+
this.#setTimer =
|
|
122
|
+
env.setTimer ??
|
|
123
|
+
((run, ms) => {
|
|
124
|
+
const id = this.#nextTimerId++;
|
|
125
|
+
this.#timeouts.set(
|
|
126
|
+
id,
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
this.#timeouts.delete(id);
|
|
129
|
+
run();
|
|
130
|
+
}, ms),
|
|
131
|
+
);
|
|
132
|
+
return id;
|
|
133
|
+
});
|
|
134
|
+
this.#clearTimer =
|
|
135
|
+
env.clearTimer ??
|
|
136
|
+
((handle) => {
|
|
137
|
+
if (typeof handle !== "number") return;
|
|
138
|
+
const timeout = this.#timeouts.get(handle);
|
|
139
|
+
if (timeout !== undefined) {
|
|
140
|
+
clearTimeout(timeout);
|
|
141
|
+
this.#timeouts.delete(handle);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Build the stable skeleton once: a volatile matrix region the poll
|
|
146
|
+
// re-renders, and a PERSISTENT terminal region a refresh never touches.
|
|
147
|
+
env.host.replaceChildren();
|
|
148
|
+
const shell = env.doc.createElement("div");
|
|
149
|
+
shell.className = "cockpit-shell";
|
|
150
|
+
this.#matrixRegion = env.doc.createElement("div");
|
|
151
|
+
this.#matrixRegion.className = "cockpit-matrix-region";
|
|
152
|
+
const terminalPanel = env.doc.createElement("section");
|
|
153
|
+
terminalPanel.className = "cockpit-terminal";
|
|
154
|
+
const title = env.doc.createElement("h2");
|
|
155
|
+
title.className = "cockpit-panel-title";
|
|
156
|
+
title.textContent = "Worker terminal";
|
|
157
|
+
terminalPanel.appendChild(title);
|
|
158
|
+
this.#terminalHost = env.doc.createElement("div");
|
|
159
|
+
this.#terminalHost.className = "cockpit-terminal-host";
|
|
160
|
+
this.#terminalHost.setAttribute("data-terminal", "host");
|
|
161
|
+
terminalPanel.appendChild(this.#terminalHost);
|
|
162
|
+
shell.appendChild(this.#matrixRegion);
|
|
163
|
+
shell.appendChild(terminalPanel);
|
|
164
|
+
env.host.appendChild(shell);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get currentStream(): string | undefined {
|
|
168
|
+
return this.#drill?.stream;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async refresh(): Promise<void> {
|
|
172
|
+
if (this.#disposed) return;
|
|
173
|
+
let report: DemandSupplyReport;
|
|
174
|
+
try {
|
|
175
|
+
report = await this.#env.fetchReport();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
this.#env.onError?.(err);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (this.#disposed) return;
|
|
181
|
+
try {
|
|
182
|
+
renderCockpit(this.#matrixRegion, this.#env.doc, cockpitView(report), {
|
|
183
|
+
onDrill: (stream) => this.drill(stream),
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
this.#env.onError?.(err);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
start(): void {
|
|
191
|
+
if (this.#disposed || this.#running) return;
|
|
192
|
+
this.#running = true;
|
|
193
|
+
const generation = ++this.#generation;
|
|
194
|
+
this.#tick(generation);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
stop(): void {
|
|
198
|
+
this.#running = false;
|
|
199
|
+
// Invalidate any in-flight tick so its finally can't reschedule after this.
|
|
200
|
+
this.#generation++;
|
|
201
|
+
if (this.#timer !== undefined) {
|
|
202
|
+
this.#clearTimer(this.#timer);
|
|
203
|
+
this.#timer = undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#tick(generation: number): void {
|
|
208
|
+
// Self-scheduling: schedule the NEXT pass only after this one settles, so a
|
|
209
|
+
// slow fetch can never overlap its successor. The generation guard drops a
|
|
210
|
+
// stale tick whose start cycle was already stopped (and possibly restarted),
|
|
211
|
+
// so a stop→start race never leaves two poll chains scheduling in parallel.
|
|
212
|
+
void this.refresh().finally(() => {
|
|
213
|
+
if (generation !== this.#generation || !this.#running || this.#disposed) return;
|
|
214
|
+
this.#timer = this.#setTimer(() => this.#tick(generation), this.#refreshMs);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
drill(stream: string): void {
|
|
219
|
+
if (this.#disposed) return;
|
|
220
|
+
if (this.#drill?.stream === stream) return;
|
|
221
|
+
// Close and drop the prior drill up-front so a synchronous failure while
|
|
222
|
+
// building the new one (e.g. createTerminal, an invalid TerminalSession
|
|
223
|
+
// credit, or connect throwing) can't leave #drill pointing at an
|
|
224
|
+
// already-closed client — currentStream would then report a dead stream.
|
|
225
|
+
// #drill is re-set only once the new client is fully wired and opened, so
|
|
226
|
+
// state stays consistent even when the new drill fails to initialize; the
|
|
227
|
+
// failure is routed to onError like every other relay/terminal error.
|
|
228
|
+
this.#drill?.client.close();
|
|
229
|
+
this.#drill = undefined;
|
|
230
|
+
// Tear down the prior terminal before mounting a fresh one so repeated
|
|
231
|
+
// drills don't leak xterm instances/listeners (replaceChildren only drops
|
|
232
|
+
// the DOM node, not the widget). Tracked in #terminal so a failed (re)drill
|
|
233
|
+
// still cleans it up on the next drill or on dispose().
|
|
234
|
+
this.#terminal?.dispose?.();
|
|
235
|
+
this.#terminal = undefined;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Fresh terminal for the newly selected worker.
|
|
239
|
+
this.#terminalHost.replaceChildren();
|
|
240
|
+
const sink = this.#env.createTerminal(this.#terminalHost);
|
|
241
|
+
this.#terminal = sink;
|
|
242
|
+
|
|
243
|
+
let session: TerminalSession | undefined;
|
|
244
|
+
const client = new RelayChannelClient({
|
|
245
|
+
connect: this.#env.connectRelay,
|
|
246
|
+
onRelay: (message) => session?.handle(message),
|
|
247
|
+
// Re-attach on EVERY (re)connect → resume-from-offset: the terminal
|
|
248
|
+
// survives a cockpit reconnect without losing or double-writing output.
|
|
249
|
+
onOpen: () => session?.attach(),
|
|
250
|
+
schedule: this.#env.schedule,
|
|
251
|
+
onError: (err) => this.#env.onError?.(err),
|
|
252
|
+
});
|
|
253
|
+
session = new TerminalSession({
|
|
254
|
+
stream,
|
|
255
|
+
sink,
|
|
256
|
+
send: (message) => client.sendRelay(message),
|
|
257
|
+
credit: this.#env.credit,
|
|
258
|
+
});
|
|
259
|
+
client.open();
|
|
260
|
+
this.#drill = { stream, client };
|
|
261
|
+
} catch (err) {
|
|
262
|
+
this.#env.onError?.(err);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
dispose(): void {
|
|
267
|
+
if (this.#disposed) return;
|
|
268
|
+
this.#disposed = true;
|
|
269
|
+
this.stop();
|
|
270
|
+
this.#drill?.client.close();
|
|
271
|
+
this.#drill = undefined;
|
|
272
|
+
this.#terminal?.dispose?.();
|
|
273
|
+
this.#terminal = undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Boot the cockpit against an injected environment. Call {@link CockpitHandle.start} to poll. */
|
|
278
|
+
export function bootCockpit(env: CockpitEnv): CockpitHandle {
|
|
279
|
+
return new Cockpit(env);
|
|
280
|
+
}
|