@softov/ahpc 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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/dist/src/ahp/channels.d.ts +146 -0
  4. package/dist/src/ahp/channels.js +423 -0
  5. package/dist/src/ahp/connection.d.ts +656 -0
  6. package/dist/src/ahp/connection.js +1 -0
  7. package/dist/src/ahp/fake.d.ts +69 -0
  8. package/dist/src/ahp/fake.js +1996 -0
  9. package/dist/src/ahp/live.d.ts +160 -0
  10. package/dist/src/ahp/live.js +2641 -0
  11. package/dist/src/ahp/operate.d.ts +36 -0
  12. package/dist/src/ahp/operate.js +56 -0
  13. package/dist/src/ahp/publish.d.ts +39 -0
  14. package/dist/src/ahp/publish.js +370 -0
  15. package/dist/src/ahp/status.d.ts +23 -0
  16. package/dist/src/ahp/status.js +23 -0
  17. package/dist/src/ahp/types.d.ts +745 -0
  18. package/dist/src/ahp/types.js +27 -0
  19. package/dist/src/app.d.ts +54 -0
  20. package/dist/src/app.js +369 -0
  21. package/dist/src/blocks.d.ts +88 -0
  22. package/dist/src/blocks.js +102 -0
  23. package/dist/src/cli/main.d.ts +13 -0
  24. package/dist/src/cli/main.js +1537 -0
  25. package/dist/src/cli/render.d.ts +43 -0
  26. package/dist/src/cli/render.js +91 -0
  27. package/dist/src/config.d.ts +63 -0
  28. package/dist/src/config.js +45 -0
  29. package/dist/src/connect.d.ts +53 -0
  30. package/dist/src/connect.js +69 -0
  31. package/dist/src/control.d.ts +189 -0
  32. package/dist/src/control.js +2241 -0
  33. package/dist/src/diff.d.ts +44 -0
  34. package/dist/src/diff.js +111 -0
  35. package/dist/src/flags.d.ts +28 -0
  36. package/dist/src/flags.js +76 -0
  37. package/dist/src/main.d.ts +10 -0
  38. package/dist/src/main.js +54 -0
  39. package/dist/src/schedule.d.ts +46 -0
  40. package/dist/src/schedule.js +140 -0
  41. package/dist/src/screens.d.ts +79 -0
  42. package/dist/src/screens.js +1322 -0
  43. package/dist/src/state.d.ts +355 -0
  44. package/dist/src/state.js +531 -0
  45. package/dist/src/terminal.d.ts +44 -0
  46. package/dist/src/terminal.js +101 -0
  47. package/dist/src/tui.d.ts +92 -0
  48. package/dist/src/tui.js +372 -0
  49. package/dist/src/view/automations.d.ts +31 -0
  50. package/dist/src/view/automations.js +76 -0
  51. package/dist/src/view/bood/art.d.ts +90 -0
  52. package/dist/src/view/bood/art.js +152 -0
  53. package/dist/src/view/bood/beetle.d.ts +7 -0
  54. package/dist/src/view/bood/beetle.js +123 -0
  55. package/dist/src/view/bood/bunny.d.ts +8 -0
  56. package/dist/src/view/bood/bunny.js +228 -0
  57. package/dist/src/view/bood/cat.d.ts +9 -0
  58. package/dist/src/view/bood/cat.js +358 -0
  59. package/dist/src/view/bood/crab.d.ts +7 -0
  60. package/dist/src/view/bood/crab.js +121 -0
  61. package/dist/src/view/bood/index.d.ts +29 -0
  62. package/dist/src/view/bood/index.js +25 -0
  63. package/dist/src/view/bood/motion.d.ts +178 -0
  64. package/dist/src/view/bood/motion.js +298 -0
  65. package/dist/src/view/bood/owl.d.ts +9 -0
  66. package/dist/src/view/bood/owl.js +285 -0
  67. package/dist/src/view/bood/registry.d.ts +36 -0
  68. package/dist/src/view/bood/registry.js +218 -0
  69. package/dist/src/view/bood/render.d.ts +31 -0
  70. package/dist/src/view/bood/render.js +196 -0
  71. package/dist/src/view/bood/sprite.d.ts +18 -0
  72. package/dist/src/view/bood/sprite.js +130 -0
  73. package/dist/src/view/bood/sprout.d.ts +8 -0
  74. package/dist/src/view/bood/sprout.js +121 -0
  75. package/dist/src/view/bood/types.d.ts +158 -0
  76. package/dist/src/view/bood/types.js +57 -0
  77. package/dist/src/view/bubble.d.ts +75 -0
  78. package/dist/src/view/bubble.js +86 -0
  79. package/dist/src/view/changes.d.ts +35 -0
  80. package/dist/src/view/changes.js +22 -0
  81. package/dist/src/view/composer.d.ts +64 -0
  82. package/dist/src/view/composer.js +192 -0
  83. package/dist/src/view/controls.d.ts +44 -0
  84. package/dist/src/view/controls.js +49 -0
  85. package/dist/src/view/creature.d.ts +69 -0
  86. package/dist/src/view/creature.js +83 -0
  87. package/dist/src/view/customizations.d.ts +28 -0
  88. package/dist/src/view/customizations.js +53 -0
  89. package/dist/src/view/details.d.ts +65 -0
  90. package/dist/src/view/details.js +65 -0
  91. package/dist/src/view/filediff.d.ts +29 -0
  92. package/dist/src/view/filediff.js +24 -0
  93. package/dist/src/view/files.d.ts +31 -0
  94. package/dist/src/view/files.js +39 -0
  95. package/dist/src/view/hitl.d.ts +43 -0
  96. package/dist/src/view/hitl.js +171 -0
  97. package/dist/src/view/icons.d.ts +13 -0
  98. package/dist/src/view/icons.js +71 -0
  99. package/dist/src/view/picker.d.ts +42 -0
  100. package/dist/src/view/picker.js +71 -0
  101. package/dist/src/view/sessionhead.d.ts +41 -0
  102. package/dist/src/view/sessionhead.js +60 -0
  103. package/dist/src/view/sessions.d.ts +34 -0
  104. package/dist/src/view/sessions.js +61 -0
  105. package/dist/src/view/terminal.d.ts +30 -0
  106. package/dist/src/view/terminal.js +32 -0
  107. package/dist/src/view/toolcall.d.ts +27 -0
  108. package/dist/src/view/toolcall.js +48 -0
  109. package/dist/src/view/transcript.d.ts +50 -0
  110. package/dist/src/view/transcript.js +60 -0
  111. package/package.json +72 -0
@@ -0,0 +1,423 @@
1
+ /** Who is holding which channel open, and what the host is saying on it. */
2
+ function bag(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
4
+ ? value
5
+ : null;
6
+ }
7
+ export function openChannels(options) {
8
+ const held = new Map();
9
+ const refused = new Map();
10
+ let client = options.client;
11
+ let seen = 0;
12
+ /**
13
+ * Which connection the current holds belong to.
14
+ *
15
+ * A subscribe in flight when the socket dies would otherwise land on the
16
+ * next connection and open a channel twice; it compares this instead.
17
+ */
18
+ let generation = 0;
19
+ const linger = options.lingerMs ?? 0;
20
+ const entry = (uri) => {
21
+ const found = held.get(uri);
22
+ if (found)
23
+ return found;
24
+ const made = { uses: 0, consumers: new Set(), opened: false, told: false, waiting: [] };
25
+ held.set(uri, made);
26
+ return made;
27
+ };
28
+ /** Note the host's counter, which advances with state rather than with messages. */
29
+ const note = (event) => {
30
+ if (event.type !== 'action')
31
+ return;
32
+ const seq = bag(event.params)?.serverSeq;
33
+ if (typeof seq === 'number' && seq > seen)
34
+ seen = seq;
35
+ };
36
+ /**
37
+ * An action the host refused, which is not an action that happened.
38
+ *
39
+ * A rejected envelope carries the action the host declined to apply, so a
40
+ * consumer that reduced it would make the very change it was just told did
41
+ * not happen - worse than the silence, because the screen then disagrees
42
+ * with the host until something else moves it. It is reported and dropped.
43
+ *
44
+ * `serverSeq` is not read off it either. The counter advances with state,
45
+ * and a refusal moved none: the number on the envelope is the one this host
46
+ * is still at, and taking it as progress would leave a gap on the next
47
+ * reconnect that nothing can fill.
48
+ *
49
+ * A rejection answers one client's dispatch. One attributed to another
50
+ * client is nothing to say to the person sitting here, so it is dropped
51
+ * without being reported; one with no origin at all is taken as ours,
52
+ * because the alternative is losing the message a host that omits it sent.
53
+ */
54
+ const rejected = (uri, event) => {
55
+ if (event.type !== 'action')
56
+ return false;
57
+ const envelope = bag(event.params);
58
+ const why = envelope?.rejectionReason;
59
+ if (typeof why !== 'string')
60
+ return false;
61
+ const from = bag(envelope?.origin)?.clientId;
62
+ const mine = options.clientId === undefined || typeof from !== 'string' || from === options.clientId;
63
+ if (mine)
64
+ options.onRejection?.(uri, why);
65
+ return true;
66
+ };
67
+ const deliver = (uri, event) => {
68
+ if (rejected(uri, event))
69
+ return;
70
+ note(event);
71
+ const channel = held.get(uri);
72
+ if (!channel)
73
+ return;
74
+ // Before the snapshot has landed there is nothing to apply this to. The
75
+ // host starts sending the moment it accepts the subscribe, which is
76
+ // earlier than it answers one.
77
+ if (!channel.opened || !channel.told) {
78
+ channel.waiting.push(event);
79
+ return;
80
+ }
81
+ for (const consumer of channel.consumers)
82
+ consumer.event(event);
83
+ };
84
+ /**
85
+ * Record a refusal, and give it to whoever is entitled to it.
86
+ *
87
+ * A reader that supplied `refused` has an opinion about what the refusal
88
+ * means to it - a session draws it in the transcript, an automations
89
+ * channel takes it as "this host serves none" - and reporting it to the
90
+ * connection as well makes an expected answer look like a fault. Only a
91
+ * refusal nobody claimed goes to `onRefusal`.
92
+ */
93
+ const refuse = (uri, message) => {
94
+ refused.set(uri, message);
95
+ const channel = held.get(uri);
96
+ let claimed = false;
97
+ for (const consumer of channel?.consumers ?? []) {
98
+ if (!consumer.refused)
99
+ continue;
100
+ consumer.refused(message);
101
+ claimed = true;
102
+ }
103
+ if (!claimed)
104
+ options.onRefusal?.(uri, message);
105
+ };
106
+ /** Read a connection's whole event stream until it ends or is superseded. */
107
+ const drain = (next, era) => {
108
+ client = next;
109
+ void (async () => {
110
+ try {
111
+ for await (const addressed of next.events()) {
112
+ if (era !== generation)
113
+ return;
114
+ deliver(addressed.channel, addressed.event);
115
+ }
116
+ }
117
+ catch { /* the connection going is the supervisor's to notice */ }
118
+ })();
119
+ };
120
+ /**
121
+ * One `subscribe` per channel at a time, however many readers arrive.
122
+ *
123
+ * Not an optimisation, and specifically about two being *in flight*. The
124
+ * reference host puts a pending marker under the channel while it restores
125
+ * the session; a subscribe arriving while that marker is unresolved
126
+ * replaces it, and the first then finds itself no longer current and is
127
+ * answered `-32001 Resource not found`, naming a channel that exists. A
128
+ * re-subscribe to a channel already open is idempotent there - it is only
129
+ * the overlap that collides. Reading a row's detail and opening the view on
130
+ * it are two readers a keystroke apart, which is exactly that overlap, and
131
+ * each was sending its own. They share this instead.
132
+ *
133
+ * The promise is dropped once it settles rather than kept: it is here to
134
+ * make concurrent readers into one request, not to hand the second reader
135
+ * an answer from before it asked.
136
+ */
137
+ const ask = (uri) => {
138
+ const channel = entry(uri);
139
+ if (channel.pending)
140
+ return channel.pending;
141
+ const era = generation;
142
+ const asking = client.subscribe(uri).then(({ result }) => {
143
+ if (era === generation && held.get(uri) === channel) {
144
+ channel.opened = true;
145
+ channel.fromSeq = result.snapshot?.fromSeq;
146
+ }
147
+ return bag(result.snapshot?.state);
148
+ });
149
+ channel.pending = asking;
150
+ const done = () => { if (channel.pending === asking)
151
+ channel.pending = undefined; };
152
+ asking.then(done, done);
153
+ return asking;
154
+ };
155
+ /** Ask the host for a channel, and give what comes back to whoever is waiting. */
156
+ const start = (uri, era) => {
157
+ const opening = entry(uri);
158
+ opening.told = false;
159
+ void (async () => {
160
+ try {
161
+ const state = await ask(uri);
162
+ if (era !== generation)
163
+ return;
164
+ const channel = held.get(uri);
165
+ if (!channel || channel.uses === 0 || channel.told)
166
+ return;
167
+ channel.told = true;
168
+ for (const consumer of channel.consumers)
169
+ consumer.opened(state);
170
+ const queued = channel.waiting.splice(0);
171
+ for (const event of queued) {
172
+ const seq = bag(event.params)?.serverSeq;
173
+ if (event.type === 'action' && typeof seq === 'number'
174
+ && channel.fromSeq !== undefined && seq <= channel.fromSeq)
175
+ continue;
176
+ for (const consumer of channel.consumers)
177
+ consumer.event(event);
178
+ }
179
+ }
180
+ catch (error) {
181
+ if (era !== generation)
182
+ return;
183
+ refuse(uri, options.reason(error));
184
+ }
185
+ })();
186
+ };
187
+ /** Let the host know nobody is reading, which is the only handle a watch has. */
188
+ /** Let the host know nobody is reading, once nobody has been for a while. */
189
+ const drop = (uri) => {
190
+ const channel = held.get(uri);
191
+ if (!channel || channel.uses > 0)
192
+ return;
193
+ const era = generation;
194
+ const release = () => {
195
+ if (era !== generation || held.get(uri) !== channel)
196
+ return;
197
+ const now = held.get(uri);
198
+ // Somebody took it back while this was waiting, which is the whole
199
+ // reason for waiting.
200
+ if (!now || now.uses > 0)
201
+ return;
202
+ // Keep the entry until the request answers. A reader returning meanwhile
203
+ // shares it; otherwise its successful subscribe still needs releasing.
204
+ if (now.pending) {
205
+ void now.pending.then(release, release);
206
+ return;
207
+ }
208
+ held.delete(uri);
209
+ if (!now.opened)
210
+ return;
211
+ void client.unsubscribe(uri).catch(() => undefined);
212
+ };
213
+ if (linger <= 0) {
214
+ release();
215
+ return;
216
+ }
217
+ clearTimeout(channel.leaving);
218
+ channel.leaving = setTimeout(() => { channel.leaving = undefined; release(); }, linger);
219
+ };
220
+ return {
221
+ open: (uri, consumer) => {
222
+ const known = refused.get(uri);
223
+ if (known !== undefined) {
224
+ // Answered already. Told on the way out rather than on the way in, so
225
+ // a caller that only wanted the handle still gets one.
226
+ queueMicrotask(() => consumer.refused?.(known));
227
+ return { release: () => undefined };
228
+ }
229
+ const channel = entry(uri);
230
+ // Taking it back before the release fired is what keeps the
231
+ // subscription alive across a screen closing and opening again.
232
+ clearTimeout(channel.leaving);
233
+ channel.leaving = undefined;
234
+ channel.consumers.add(consumer);
235
+ /*
236
+ * Alone, rather than the only hold. A snapshot read holds the channel
237
+ * too, and counting it as a reader left a view opened during one with
238
+ * nobody to hand it the state: `state` does not deliver to consumers,
239
+ * and the view was not first, so nothing did. What arrived was a pane
240
+ * with a session's name on it and none of the session in it.
241
+ *
242
+ * Still a fresh subscribe, even when the channel was only lingering:
243
+ * asking again is how this reader gets a snapshot, and the host is
244
+ * still holding the channel because no `unsubscribe` went out. Where a
245
+ * subscribe is already out, `ask` hands back that one instead of
246
+ * sending a second - which is the request the host answers by
247
+ * cancelling the first.
248
+ */
249
+ const alone = channel.consumers.size === 1;
250
+ channel.uses += 1;
251
+ if (alone && channel.initial !== undefined) {
252
+ // The handshake already asked, and this is the answer it got.
253
+ const opening = channel.initial;
254
+ channel.initial = undefined;
255
+ channel.told = true;
256
+ queueMicrotask(() => {
257
+ if (!channel.consumers.has(consumer))
258
+ return;
259
+ consumer.opened(opening);
260
+ const queued = channel.waiting.splice(0);
261
+ for (const event of queued)
262
+ for (const one of channel.consumers)
263
+ one.event(event);
264
+ });
265
+ }
266
+ else if (alone) {
267
+ channel.told = false;
268
+ start(uri, generation);
269
+ }
270
+ else if (channel.told) {
271
+ // The readers keep their own reduced state. Ask once for a fresh
272
+ // baseline and hand it to all of them, buffering events until it lands.
273
+ // A null snapshot would leave the newcomer with an empty conversation.
274
+ start(uri, generation);
275
+ }
276
+ let holding = true;
277
+ return {
278
+ release: () => {
279
+ if (!holding)
280
+ return;
281
+ holding = false;
282
+ channel.consumers.delete(consumer);
283
+ channel.uses -= 1;
284
+ if (channel.uses === 0)
285
+ drop(uri);
286
+ },
287
+ };
288
+ },
289
+ state: async (uri) => {
290
+ const known = refused.get(uri);
291
+ if (known !== undefined)
292
+ return null;
293
+ /*
294
+ * A read is a hold that is given up straight away, not a subscribe
295
+ * followed by an unsubscribe.
296
+ *
297
+ * The difference is what the host sees. Reading a session's snapshot
298
+ * and then opening the view on it are a moment apart, and releasing the
299
+ * first the instant it is done puts an `unsubscribe` between them - at
300
+ * which point a host that evicts on the last subscriber leaving is
301
+ * restoring the session from disk exactly as the view asks for it. Going
302
+ * through the same counting and the same pause as any other reader means
303
+ * the two coalesce into one subscription and nothing is let go in the
304
+ * middle.
305
+ */
306
+ const channel = entry(uri);
307
+ clearTimeout(channel.leaving);
308
+ channel.leaving = undefined;
309
+ channel.uses += 1;
310
+ try {
311
+ return await ask(uri);
312
+ }
313
+ catch (error) {
314
+ refuse(uri, options.reason(error));
315
+ return null;
316
+ }
317
+ finally {
318
+ channel.uses -= 1;
319
+ if (channel.uses === 0)
320
+ drop(uri);
321
+ }
322
+ },
323
+ held: () => [...held.keys()].filter((uri) => (held.get(uri)?.uses ?? 0) > 0),
324
+ seq: () => seen,
325
+ refusal: (uri) => refused.get(uri),
326
+ forget: (uri) => { if (uri === undefined)
327
+ refused.clear();
328
+ else
329
+ refused.delete(uri); },
330
+ drain: (next) => { drain(next, generation); },
331
+ resume: (next, result) => {
332
+ generation += 1;
333
+ client = next;
334
+ // Whatever was in flight belonged to the socket that went. Kept, it
335
+ // would be handed to the first reader on the new one as an answer that
336
+ // is never coming.
337
+ for (const channel of held.values())
338
+ channel.pending = undefined;
339
+ // Before anything is applied, so an action the host sends while this is
340
+ // still catching up is queued rather than dropped on the floor.
341
+ drain(next, generation);
342
+ const gone = new Set(result.missing ?? []);
343
+ for (const uri of gone) {
344
+ const channel = held.get(uri);
345
+ if (!channel)
346
+ continue;
347
+ held.delete(uri);
348
+ for (const consumer of channel.consumers) {
349
+ consumer.refused?.('This session is no longer on the host.');
350
+ }
351
+ }
352
+ // Replay: the host could name everything this client missed, so the
353
+ // mirrors it has are still good and only the gap needs filling.
354
+ for (const envelope of result.replayed ?? []) {
355
+ const uri = bag(envelope)?.channel;
356
+ if (typeof uri !== 'string')
357
+ continue;
358
+ const channel = held.get(uri);
359
+ if (!channel)
360
+ continue;
361
+ const event = { type: 'action', params: envelope };
362
+ note(event);
363
+ for (const consumer of channel.consumers)
364
+ consumer.event(event);
365
+ }
366
+ if (result.replayed !== undefined) {
367
+ // Replay is what the host sends *instead of* a fresh snapshot, so the
368
+ // subscriptions behind it are ones it restored itself and none of them
369
+ // needs asking for again.
370
+ for (const channel of held.values()) {
371
+ if (channel.uses === 0 || channel.opened)
372
+ continue;
373
+ channel.opened = true;
374
+ channel.told = true;
375
+ const queued = channel.waiting.splice(0);
376
+ for (const event of queued) {
377
+ for (const consumer of channel.consumers)
378
+ consumer.event(event);
379
+ }
380
+ }
381
+ }
382
+ // Snapshot: the gap was longer than the host's buffer, so every mirror
383
+ // is stale and every reader is handed the state rather than a delta.
384
+ for (const snapshot of result.resumed ?? []) {
385
+ const channel = held.get(snapshot.resource);
386
+ if (!channel)
387
+ continue;
388
+ channel.opened = true;
389
+ channel.told = true;
390
+ channel.waiting.length = 0;
391
+ for (const consumer of channel.consumers)
392
+ consumer.opened(bag(snapshot.state));
393
+ }
394
+ for (const [uri, channel] of held) {
395
+ if (channel.uses === 0)
396
+ continue;
397
+ // A channel the host restored but sent no snapshot for is still being
398
+ // served; one it never restored has to be asked for again.
399
+ if (!channel.opened)
400
+ start(uri, generation);
401
+ }
402
+ },
403
+ adopt: (uri, state) => {
404
+ const channel = entry(uri);
405
+ channel.opened = true;
406
+ channel.told = false;
407
+ channel.initial = state;
408
+ },
409
+ detach: () => {
410
+ generation += 1;
411
+ for (const [uri, channel] of held) {
412
+ if (channel.uses === 0) {
413
+ clearTimeout(channel.leaving);
414
+ held.delete(uri);
415
+ }
416
+ channel.opened = false;
417
+ channel.told = false;
418
+ channel.waiting.length = 0;
419
+ channel.pending = undefined;
420
+ }
421
+ },
422
+ };
423
+ }