@nanobpm/agentic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,252 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import type { RelayPayload } from "../protocol/index.ts";
5
+
6
+ import { type RelayOutbound, TerminalSession } from "./terminal-session.ts";
7
+
8
+ interface Harness {
9
+ readonly session: TerminalSession;
10
+ readonly sent: RelayOutbound[];
11
+ readonly writes: string[];
12
+ data(offset: number, chunk: string, stream?: string): RelayPayload;
13
+ }
14
+
15
+ function harness(options: { from?: number; credit?: number; stream?: string } = {}): Harness {
16
+ const stream = options.stream ?? "worker-1";
17
+ const sent: RelayOutbound[] = [];
18
+ const writes: string[] = [];
19
+ const session = new TerminalSession({
20
+ stream,
21
+ sink: { write: (chunk) => writes.push(chunk) },
22
+ send: (message) => sent.push(message),
23
+ from: options.from,
24
+ credit: options.credit,
25
+ });
26
+ return {
27
+ session,
28
+ sent,
29
+ writes,
30
+ data: (offset, chunk, s = stream) => ({ stream: s, offset, chunk }),
31
+ };
32
+ }
33
+
34
+ test("attach subscribes from the initial offset with the configured credit", () => {
35
+ const h = harness({ from: 0, credit: 256 });
36
+ h.session.attach();
37
+ assert.deepEqual(h.sent, [{ op: "subscribe", stream: "worker-1", from: 0, credit: 256 }]);
38
+ });
39
+
40
+ test("data chunks are written in order and advance the resume offset", () => {
41
+ const h = harness();
42
+ h.session.attach();
43
+ h.session.handle(h.data(0, "hello "));
44
+ h.session.handle(h.data(1, "world"));
45
+ assert.deepEqual(h.writes, ["hello ", "world"]);
46
+ assert.equal(h.session.nextOffset, 2);
47
+ });
48
+
49
+ test("a chunk at Number.MAX_SAFE_INTEGER fails fast instead of overflowing nextOffset", () => {
50
+ // Advancing nextOffset past MAX_SAFE_INTEGER would produce an unsafe integer
51
+ // that loses precision on any JSON round-trip when echoed back in subscribe.from,
52
+ // silently corrupting resume semantics. Guard the increment so it throws instead.
53
+ const h = harness();
54
+ h.session.attach();
55
+ assert.throws(
56
+ () => h.session.handle(h.data(Number.MAX_SAFE_INTEGER, "x")),
57
+ RangeError,
58
+ "a boundary chunk must reject rather than corrupt the resume offset",
59
+ );
60
+ // The apply must be atomic: rejecting the boundary chunk must NOT write it to
61
+ // the sink. A write-then-throw would leave output applied but nextOffset not
62
+ // advanced, so the chunk re-delivers and duplicates on reconnect.
63
+ assert.deepEqual(h.writes, [], "a rejected boundary chunk must not be written");
64
+ assert.equal(h.session.nextOffset, 0);
65
+ });
66
+
67
+ test("a reconnect resumes from nextOffset — no lost and no duplicated output", () => {
68
+ const h = harness();
69
+ h.session.attach(); // subscribe from 0
70
+ h.session.handle(h.data(0, "a"));
71
+ h.session.handle(h.data(1, "b"));
72
+ assert.equal(h.session.nextOffset, 2);
73
+
74
+ // Socket drops and the client reconnects → the session re-attaches.
75
+ h.session.attach();
76
+ assert.deepEqual(h.sent[1], { op: "subscribe", stream: "worker-1", from: 2, credit: 1024 });
77
+
78
+ // The hub replays from offset 2 (the retained tail). The boundary is not
79
+ // re-sent because we resumed *past* it; new chunks flow through.
80
+ h.session.handle(h.data(2, "c"));
81
+ h.session.handle(h.data(3, "d"));
82
+ assert.deepEqual(h.writes, ["a", "b", "c", "d"]);
83
+ assert.equal(h.session.nextOffset, 4);
84
+ });
85
+
86
+ test("an already-applied replayed chunk (offset < nextOffset) is dropped idempotently", () => {
87
+ const h = harness();
88
+ h.session.attach();
89
+ h.session.handle(h.data(0, "a"));
90
+ h.session.handle(h.data(1, "b"));
91
+
92
+ // Reconnect and the hub re-delivers the already-seen tail from a lower offset
93
+ // (e.g. a subscribe raced with in-flight data): duplicates must not reappear.
94
+ h.session.attach();
95
+ h.session.handle(h.data(0, "a"));
96
+ h.session.handle(h.data(1, "b"));
97
+ h.session.handle(h.data(2, "c"));
98
+ assert.deepEqual(h.writes, ["a", "b", "c"]);
99
+ assert.equal(h.session.nextOffset, 3);
100
+ });
101
+
102
+ test("data for another stream is ignored", () => {
103
+ const h = harness();
104
+ h.session.attach();
105
+ h.session.handle(h.data(0, "mine"));
106
+ h.session.handle(h.data(1, "theirs", "worker-2"));
107
+ assert.deepEqual(h.writes, ["mine"]);
108
+ assert.equal(h.session.nextOffset, 1);
109
+ });
110
+
111
+ test("a subscribed ack reporting a gap fires onGap and records it", () => {
112
+ let gaps = 0;
113
+ const session = new TerminalSession({
114
+ stream: "worker-1",
115
+ sink: { write: () => {} },
116
+ send: () => {},
117
+ onGap: () => {
118
+ gaps += 1;
119
+ },
120
+ });
121
+ session.handle({ op: "subscribed", stream: "worker-1", gap: true, nextOffset: 5 });
122
+ assert.equal(gaps, 1);
123
+ assert.equal(session.gap, true);
124
+ });
125
+
126
+ test("a subscribed ack with no gap does not fire onGap", () => {
127
+ let gaps = 0;
128
+ const session = new TerminalSession({
129
+ stream: "worker-1",
130
+ sink: { write: () => {} },
131
+ send: () => {},
132
+ onGap: () => {
133
+ gaps += 1;
134
+ },
135
+ });
136
+ session.handle({ op: "subscribed", stream: "worker-1", gap: false, nextOffset: 0 });
137
+ assert.equal(gaps, 0);
138
+ assert.equal(session.gap, false);
139
+ });
140
+
141
+ test("a later no-gap ack clears a gap recorded by an earlier ack", () => {
142
+ const session = new TerminalSession({
143
+ stream: "worker-1",
144
+ sink: { write: () => {} },
145
+ send: () => {},
146
+ });
147
+ session.handle({ op: "subscribed", stream: "worker-1", gap: true, nextOffset: 5 });
148
+ assert.equal(session.gap, true);
149
+ session.handle({ op: "subscribed", stream: "worker-1", gap: false, nextOffset: 5 });
150
+ assert.equal(session.gap, false);
151
+ });
152
+
153
+ test("a subscribed ack with a negative/unsafe nextOffset is rejected and does not corrupt the resume point", () => {
154
+ // nextOffset is echoed into subscribe.from on the next attach, so a
155
+ // negative/NaN/unsafe value from a buggy or malicious transport that bypasses
156
+ // RelayChannelClient's validation must fail fast rather than corrupt resume.
157
+ const h = harness({ from: 4 });
158
+ h.session.attach();
159
+ assert.throws(
160
+ () => h.session.handle({ op: "subscribed", stream: "worker-1", gap: false, nextOffset: -1 }),
161
+ RangeError,
162
+ "a negative ack nextOffset must reject",
163
+ );
164
+ assert.throws(
165
+ () =>
166
+ h.session.handle({ op: "subscribed", stream: "worker-1", gap: false, nextOffset: Number.MAX_SAFE_INTEGER + 1 }),
167
+ RangeError,
168
+ "an unsafe ack nextOffset must reject",
169
+ );
170
+ // A rejected ack must leave the resume point untouched.
171
+ assert.equal(h.session.nextOffset, 4);
172
+ });
173
+
174
+ test("an ack whose head is below our resume point clamps nextOffset down so fresh chunks are not dropped", () => {
175
+ // (we are ahead of the stream). Without a clamp, offsets 3,4,… stay below our
176
+ // resume point and every fresh chunk is dropped as a stale replay — output lost.
177
+ const h = harness({ from: 10 });
178
+ h.session.attach();
179
+ h.session.handle({ op: "subscribed", stream: "worker-1", gap: false, nextOffset: 3 });
180
+ assert.equal(h.session.nextOffset, 3);
181
+ // Chunks from the restarted hub now apply instead of being dropped.
182
+ h.session.handle(h.data(3, "fresh"));
183
+ h.session.handle(h.data(4, "output"));
184
+ assert.deepEqual(h.writes, ["fresh", "output"]);
185
+ assert.equal(h.session.nextOffset, 5);
186
+ });
187
+
188
+ test("an ack whose head is at or above our resume point never advances nextOffset (no skipped chunks)", () => {
189
+ // The normal case: the hub has more data than we have applied. The ack must
190
+ // NOT bump nextOffset up to the head, or the un-applied tail would be skipped.
191
+ const h = harness({ from: 2 });
192
+ h.session.attach();
193
+ h.session.handle({ op: "subscribed", stream: "worker-1", gap: false, nextOffset: 9 });
194
+ assert.equal(h.session.nextOffset, 2);
195
+ h.session.handle(h.data(2, "tail"));
196
+ assert.deepEqual(h.writes, ["tail"]);
197
+ assert.equal(h.session.nextOffset, 3);
198
+ });
199
+
200
+ test("resuming from a non-zero offset subscribes there and drops earlier replays", () => {
201
+ const h = harness({ from: 10 });
202
+ h.session.attach();
203
+ assert.deepEqual(h.sent[0], { op: "subscribe", stream: "worker-1", from: 10, credit: 1024 });
204
+ h.session.handle(h.data(8, "old")); // below resume point
205
+ h.session.handle(h.data(10, "new"));
206
+ assert.deepEqual(h.writes, ["new"]);
207
+ assert.equal(h.session.nextOffset, 11);
208
+ });
209
+
210
+ test("grant sends a credit message", () => {
211
+ const h = harness();
212
+ h.session.grant(512);
213
+ assert.deepEqual(h.sent, [{ op: "credit", credit: 512 }]);
214
+ });
215
+
216
+ test("an invalid from offset is rejected at construction", () => {
217
+ assert.throws(
218
+ () => new TerminalSession({ stream: "s", sink: { write: () => {} }, send: () => {}, from: -1 }),
219
+ RangeError,
220
+ );
221
+ });
222
+
223
+ test("an unsafe-integer from offset is rejected at construction", () => {
224
+ assert.throws(
225
+ () =>
226
+ new TerminalSession({
227
+ stream: "s",
228
+ sink: { write: () => {} },
229
+ send: () => {},
230
+ from: Number.MAX_SAFE_INTEGER + 1,
231
+ }),
232
+ RangeError,
233
+ );
234
+ });
235
+
236
+ test("a non-positive or unsafe credit is rejected at construction", () => {
237
+ for (const credit of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN]) {
238
+ assert.throws(
239
+ () => new TerminalSession({ stream: "s", sink: { write: () => {} }, send: () => {}, credit }),
240
+ RangeError,
241
+ `credit ${credit} should be rejected`,
242
+ );
243
+ }
244
+ });
245
+
246
+ test("grant rejects a non-positive or unsafe credit", () => {
247
+ const h = harness();
248
+ for (const credit of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN]) {
249
+ assert.throws(() => h.session.grant(credit), RangeError, `grant(${credit}) should be rejected`);
250
+ }
251
+ });
252
+
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The worker terminal session — S8's drill-into-a-worker live terminal.
3
+ *
4
+ * A consumer of the S5 relay sub-protocol that **survives a cockpit reconnect**
5
+ * via resume-from-offset. It is deliberately timer-free and transport-free: it
6
+ * emits the relay messages to send ({@link RelaySend}) and consumes the relay
7
+ * messages received ({@link TerminalSession.handle}), tracking exactly one piece
8
+ * of durable state — `nextOffset`, the offset to resume from. On any (re)attach
9
+ * it re-subscribes from `nextOffset`, and it drops any replayed chunk it has
10
+ * already applied, so a reconnect neither loses nor double-writes output (within
11
+ * the relay ring's retained window). If the hub's `subscribed` ack reports a
12
+ * `nextOffset` (stream head) *below* our resume point — a hub restart/reset left
13
+ * us ahead of the stream — it clamps `nextOffset` back down so fresh chunks are
14
+ * not dropped as stale.
15
+ *
16
+ * The S5 relay wire (see `@nanobpm/agentic-relay`):
17
+ * - outbound `{ op: "subscribe", stream, from, credit }` — (re)attach and resume,
18
+ * - outbound `{ op: "credit", credit }` — grant more bulk credit,
19
+ * - inbound `{ op: "subscribed", stream, gap, nextOffset }` — the resume ack
20
+ * (`gap: boolean` — the S5 wire flags whether chunks aged out),
21
+ * - inbound {@link RelayPayload} `{ stream, offset, chunk }` — a data chunk.
22
+ */
23
+ import type { RelayPayload } from "../protocol/index.ts";
24
+ import { addSafeInt, isNonNegInt, isPosInt } from "../relay/index.ts";
25
+
26
+ /** The terminal sink the session writes decoded output to (xterm.js satisfies this). */
27
+ export interface TerminalSink {
28
+ /** Append a chunk of terminal output. */
29
+ write(chunk: string): void;
30
+ /** Tear down the underlying terminal widget and its listeners, if any. */
31
+ dispose?(): void;
32
+ }
33
+
34
+ /** An outbound relay message the session asks its transport to send. */
35
+ export type RelayOutbound =
36
+ | { readonly op: "subscribe"; readonly stream: string; readonly from: number; readonly credit: number }
37
+ | { readonly op: "credit"; readonly credit: number };
38
+
39
+ /** An inbound relay message the session consumes (a data chunk or a resume ack). */
40
+ export type RelayInbound =
41
+ | RelayPayload
42
+ | { readonly op: "subscribed"; readonly stream: string; readonly gap: boolean; readonly nextOffset: number };
43
+
44
+ /** Sends one outbound relay message over the channel. */
45
+ export type RelaySend = (message: RelayOutbound) => void;
46
+
47
+ export interface TerminalSessionOptions {
48
+ /** The relay stream id (one worker's terminal). */
49
+ readonly stream: string;
50
+ /** Where decoded output is written. */
51
+ readonly sink: TerminalSink;
52
+ /** Emits outbound relay messages. */
53
+ readonly send: RelaySend;
54
+ /** Bulk credit requested on each (re)subscribe. Default 1024. */
55
+ readonly credit?: number;
56
+ /** Offset to resume from on first attach. Default 0 (from the start). */
57
+ readonly from?: number;
58
+ /** Notified when the resume ack reports a gap (chunks aged out of the ring). */
59
+ readonly onGap?: () => void;
60
+ }
61
+
62
+ const DEFAULT_CREDIT = 1024;
63
+
64
+ function isRelayData(message: RelayInbound): message is RelayPayload {
65
+ return !("op" in message);
66
+ }
67
+
68
+ /**
69
+ * A resume-from-offset consumer of one relay stream. Construct once per worker
70
+ * drill-in; call {@link attach} on every (re)connection and feed every inbound
71
+ * relay message to {@link handle}.
72
+ */
73
+ export class TerminalSession {
74
+ readonly #stream: string;
75
+ readonly #sink: TerminalSink;
76
+ readonly #send: RelaySend;
77
+ readonly #credit: number;
78
+ readonly #onGap: TerminalSessionOptions["onGap"];
79
+ /** The next offset we still need — everything below it has been applied. */
80
+ #nextOffset: number;
81
+ /** Whether the most recent resume ack reported a gap (retained window overrun). */
82
+ #gap = false;
83
+
84
+ constructor(options: TerminalSessionOptions) {
85
+ this.#stream = options.stream;
86
+ this.#sink = options.sink;
87
+ this.#send = options.send;
88
+ this.#credit = options.credit ?? DEFAULT_CREDIT;
89
+ if (!isPosInt(this.#credit)) {
90
+ throw new RangeError(`TerminalSession.credit must be a positive safe integer, got ${this.#credit}`);
91
+ }
92
+ this.#onGap = options.onGap;
93
+ const from = options.from ?? 0;
94
+ if (!isNonNegInt(from)) {
95
+ throw new RangeError(`TerminalSession.from must be a non-negative safe integer, got ${from}`);
96
+ }
97
+ this.#nextOffset = from;
98
+ }
99
+
100
+ /** The stream id this session follows. */
101
+ get stream(): string {
102
+ return this.#stream;
103
+ }
104
+
105
+ /** The offset the session will resume from on the next {@link attach}. */
106
+ get nextOffset(): number {
107
+ return this.#nextOffset;
108
+ }
109
+
110
+ /** Whether the most recent resume ack reported a gap (false when none was lost). */
111
+ get gap(): boolean {
112
+ return this.#gap;
113
+ }
114
+
115
+ /**
116
+ * (Re)subscribe to the stream, resuming from {@link nextOffset}. Call this on
117
+ * first connect AND after every reconnect — because it always resumes from the
118
+ * offset just past the last applied chunk, a reconnect replays only the
119
+ * un-applied tail (no loss) and re-delivered chunks below `nextOffset` are
120
+ * dropped by {@link handle} (no duplication).
121
+ */
122
+ attach(): void {
123
+ this.#send({ op: "subscribe", stream: this.#stream, from: this.#nextOffset, credit: this.#credit });
124
+ }
125
+
126
+ /** Grant additional bulk credit (backpressure release) mid-stream. */
127
+ grant(credit: number): void {
128
+ if (!isPosInt(credit)) {
129
+ throw new RangeError(`TerminalSession.grant credit must be a positive safe integer, got ${credit}`);
130
+ }
131
+ this.#send({ op: "credit", credit });
132
+ }
133
+
134
+ /**
135
+ * Process one inbound relay message. A data chunk at or beyond `nextOffset` is
136
+ * written and advances the resume point; a chunk below it (a duplicate replay
137
+ * after a reconnect) is dropped. A resume ack records any gap and clamps the
138
+ * resume point down to the hub's head when we are ahead of it.
139
+ */
140
+ handle(message: RelayInbound): void {
141
+ if (isRelayData(message)) {
142
+ this.#onData(message);
143
+ return;
144
+ }
145
+ if (message.op === "subscribed" && message.stream === this.#stream) {
146
+ this.#onSubscribed(message.gap, message.nextOffset);
147
+ }
148
+ }
149
+
150
+ #onData(data: RelayPayload): void {
151
+ if (data.stream !== this.#stream) return;
152
+ // Idempotent apply: a reconnect resubscribes from nextOffset, so the hub may
153
+ // re-deliver the boundary chunk; anything we have already applied is dropped.
154
+ if (data.offset < this.#nextOffset) return;
155
+ // Compute the next resume point BEFORE writing so the apply is atomic: a
156
+ // chunk at Number.MAX_SAFE_INTEGER makes addSafeInt throw, and it must throw
157
+ // before we touch the sink — otherwise the chunk is written but #nextOffset
158
+ // is not advanced, leaving a partially-applied state that re-delivers (and
159
+ // so duplicates) the chunk on reconnect. Advancing via addSafeInt also fails
160
+ // fast rather than overflowing into an unsafe nextOffset — that value would
161
+ // later be echoed in subscribe.from and lose precision on any JSON
162
+ // round-trip, silently corrupting resume semantics.
163
+ const nextOffset = addSafeInt(data.offset, 1, "nextOffset");
164
+ this.#sink.write(data.chunk);
165
+ this.#nextOffset = nextOffset;
166
+ }
167
+
168
+ #onSubscribed(gap: boolean, nextOffset: number): void {
169
+ // Validate the ack's resume point exactly as the constructor validates
170
+ // `from`: `nextOffset` is echoed back into subscribe.from on the next
171
+ // attach, so a negative/NaN/unsafe value (from a buggy or malicious
172
+ // transport that bypasses RelayChannelClient's validation) would corrupt
173
+ // resume semantics or lose precision on a JSON round-trip. Fail fast.
174
+ if (!isNonNegInt(nextOffset)) {
175
+ throw new RangeError(
176
+ `TerminalSession.subscribed nextOffset must be a non-negative safe integer, got ${nextOffset}`,
177
+ );
178
+ }
179
+ // Clamp our resume point down to the hub's current head when we are ahead of
180
+ // it. Without this, a hub restart/reset (or a `from` seeded past the head)
181
+ // leaves #nextOffset above every offset the hub will now emit, so #onData
182
+ // silently drops all fresh chunks until the stream catches back up — losing
183
+ // terminal output, possibly indefinitely. Only clamp DOWN: a head at or above
184
+ // #nextOffset is the normal case and must not skip un-applied chunks.
185
+ if (nextOffset < this.#nextOffset) {
186
+ this.#nextOffset = nextOffset;
187
+ }
188
+ // Record the gap on every ack so a later no-gap resume clears a prior gap.
189
+ this.#gap = gap;
190
+ if (gap) {
191
+ this.#onGap?.();
192
+ }
193
+ }
194
+ }
@@ -0,0 +1,117 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import type { DemandSupplyReport } from "../demand/index.ts";
5
+ import type { DiversityReport } from "../vocab/index.ts";
6
+
7
+ import { cockpitView } from "./view.ts";
8
+
9
+ const greenDiversity: DiversityReport = { status: "green", roles: [] };
10
+
11
+ const amberDiversity: DiversityReport = {
12
+ status: "amber",
13
+ roles: [
14
+ {
15
+ token: "planning.plan",
16
+ seatsDistinctFamily: false,
17
+ assignments: [
18
+ { seat: "red", family: "acme", instance: "a" },
19
+ { seat: "blue", family: "acme", instance: "b" },
20
+ ],
21
+ collidingFamilies: ["acme"],
22
+ status: "amber",
23
+ },
24
+ ],
25
+ };
26
+
27
+ function report(overrides: Partial<DemandSupplyReport> = {}): DemandSupplyReport {
28
+ return {
29
+ networks: [],
30
+ missing: [],
31
+ diversity: greenDiversity,
32
+ status: "green",
33
+ nonAgentic: [],
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ test("a fully-served, green report yields a green view with no missing lights", () => {
39
+ const view = cockpitView(
40
+ report({
41
+ networks: [
42
+ {
43
+ network: "ci",
44
+ tokens: [{ token: "ci.build", supply: 2, instances: ["a", "b"], satisfied: true }],
45
+ missing: [],
46
+ },
47
+ ],
48
+ }),
49
+ );
50
+
51
+ assert.equal(view.status, "green");
52
+ assert.equal(view.missing.length, 0);
53
+ assert.equal(view.missingLights.length, 0);
54
+ assert.equal(view.networks[0]?.status, "green");
55
+ assert.equal(view.networks[0]?.tokens[0]?.status, "green");
56
+ assert.equal(view.diversityLight.status, "green");
57
+ });
58
+
59
+ test("a missing agent type lights the token, the row and the report RED", () => {
60
+ const view = cockpitView(
61
+ report({
62
+ status: "red",
63
+ missing: ["planning.plan#blue"],
64
+ networks: [
65
+ {
66
+ network: "planning",
67
+ tokens: [
68
+ { token: "planning.plan#blue", supply: 0, instances: [], satisfied: false },
69
+ { token: "planning.plan#red", supply: 1, instances: ["a"], satisfied: true },
70
+ ],
71
+ missing: ["planning.plan#blue"],
72
+ },
73
+ ],
74
+ }),
75
+ );
76
+
77
+ assert.equal(view.status, "red");
78
+ assert.equal(view.networks[0]?.status, "red");
79
+ const [missingTok, servedTok] = view.networks[0]?.tokens ?? [];
80
+ assert.equal(missingTok?.status, "red");
81
+ assert.equal(servedTok?.status, "green");
82
+
83
+ assert.equal(view.missingLights.length, 1);
84
+ assert.equal(view.missingLights[0]?.id, "missing:planning.plan#blue");
85
+ assert.equal(view.missingLights[0]?.label, "planning.plan#blue");
86
+ assert.equal(view.missingLights[0]?.status, "red");
87
+ });
88
+
89
+ test("the diversity SLO is surfaced as a light with the colliding family in the detail", () => {
90
+ const view = cockpitView(report({ status: "amber", diversity: amberDiversity }));
91
+
92
+ assert.equal(view.status, "amber");
93
+ assert.equal(view.diversity, amberDiversity);
94
+ assert.equal(view.diversityLight.status, "amber");
95
+ assert.match(view.diversityLight.detail ?? "", /planning\.plan/);
96
+ assert.match(view.diversityLight.detail ?? "", /acme/);
97
+ });
98
+
99
+ test("non-agentic task types pass through unchanged for operator visibility", () => {
100
+ const view = cockpitView(report({ nonAgentic: ["send-email", "resize-image"] }));
101
+ assert.deepEqual(view.nonAgentic, ["send-email", "resize-image"]);
102
+ });
103
+
104
+ test("the derivation is pure — the same report yields a deep-equal view", () => {
105
+ const input = report({
106
+ status: "red",
107
+ missing: ["qa.review#blue"],
108
+ networks: [
109
+ {
110
+ network: "qa",
111
+ tokens: [{ token: "qa.review#blue", supply: 0, instances: [], satisfied: false }],
112
+ missing: ["qa.review#blue"],
113
+ },
114
+ ],
115
+ });
116
+ assert.deepEqual(cockpitView(input), cockpitView(input));
117
+ });