@abaigram/abai-stub 0.0.0-stage → 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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/dist/audit.d.ts +70 -0
  4. package/dist/audit.d.ts.map +1 -0
  5. package/dist/audit.js +166 -0
  6. package/dist/audit.js.map +1 -0
  7. package/dist/cells/fit.d.ts +6 -0
  8. package/dist/cells/fit.d.ts.map +1 -0
  9. package/dist/cells/fit.js +44 -0
  10. package/dist/cells/fit.js.map +1 -0
  11. package/dist/content.d.ts +17 -0
  12. package/dist/content.d.ts.map +1 -0
  13. package/dist/content.js +71 -0
  14. package/dist/content.js.map +1 -0
  15. package/dist/endpoint.d.ts +90 -0
  16. package/dist/endpoint.d.ts.map +1 -0
  17. package/dist/endpoint.js +789 -0
  18. package/dist/endpoint.js.map +1 -0
  19. package/dist/entry.d.ts +36 -0
  20. package/dist/entry.d.ts.map +1 -0
  21. package/dist/entry.js +409 -0
  22. package/dist/entry.js.map +1 -0
  23. package/dist/env.d.ts +38 -0
  24. package/dist/env.d.ts.map +1 -0
  25. package/dist/env.js +69 -0
  26. package/dist/env.js.map +1 -0
  27. package/dist/harness-binding.d.ts +223 -0
  28. package/dist/harness-binding.d.ts.map +1 -0
  29. package/dist/harness-binding.js +589 -0
  30. package/dist/harness-binding.js.map +1 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +17 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/manifest/index.d.ts +3 -0
  36. package/dist/manifest/index.d.ts.map +1 -0
  37. package/dist/manifest/index.js +3 -0
  38. package/dist/manifest/index.js.map +1 -0
  39. package/dist/manifest/manifest.d.ts +172 -0
  40. package/dist/manifest/manifest.d.ts.map +1 -0
  41. package/dist/manifest/manifest.js +443 -0
  42. package/dist/manifest/manifest.js.map +1 -0
  43. package/dist/manifest/render.d.ts +6 -0
  44. package/dist/manifest/render.d.ts.map +1 -0
  45. package/dist/manifest/render.js +39 -0
  46. package/dist/manifest/render.js.map +1 -0
  47. package/dist/null-harness.d.ts +32 -0
  48. package/dist/null-harness.d.ts.map +1 -0
  49. package/dist/null-harness.js +162 -0
  50. package/dist/null-harness.js.map +1 -0
  51. package/dist/program.d.ts +250 -0
  52. package/dist/program.d.ts.map +1 -0
  53. package/dist/program.js +877 -0
  54. package/dist/program.js.map +1 -0
  55. package/dist/region.d.ts +72 -0
  56. package/dist/region.d.ts.map +1 -0
  57. package/dist/region.js +92 -0
  58. package/dist/region.js.map +1 -0
  59. package/dist/registry.d.ts +57 -0
  60. package/dist/registry.d.ts.map +1 -0
  61. package/dist/registry.js +101 -0
  62. package/dist/registry.js.map +1 -0
  63. package/dist/wire/descriptor.d.ts +38 -0
  64. package/dist/wire/descriptor.d.ts.map +1 -0
  65. package/dist/wire/descriptor.js +144 -0
  66. package/dist/wire/descriptor.js.map +1 -0
  67. package/dist/wire/framing.d.ts +11 -0
  68. package/dist/wire/framing.d.ts.map +1 -0
  69. package/dist/wire/framing.js +42 -0
  70. package/dist/wire/framing.js.map +1 -0
  71. package/dist/wire/index.d.ts +4 -0
  72. package/dist/wire/index.d.ts.map +1 -0
  73. package/dist/wire/index.js +4 -0
  74. package/dist/wire/index.js.map +1 -0
  75. package/dist/wire/protocol.d.ts +255 -0
  76. package/dist/wire/protocol.d.ts.map +1 -0
  77. package/dist/wire/protocol.js +15 -0
  78. package/dist/wire/protocol.js.map +1 -0
  79. package/package.json +36 -5
@@ -0,0 +1,789 @@
1
+ // The stub's sidecar endpoint (abaigrammar section 6.3): the receiving end a
2
+ // harness in another process connects to. Wire protocol v1 over a Unix
3
+ // socket, listening from startup so an outside harness reaches a running
4
+ // program that never calls a touchpoint, and so a lost connection is
5
+ // restored by the harness connecting again.
6
+ //
7
+ // One binding, many connections. The first connection attaches the program
8
+ // (one DirectBinding, which every channel runs through); the last one
9
+ // leaving detaches it, so what was open settles as detached. Every act is
10
+ // audited under the connection that made it. Decisions go to the one
11
+ // connection that claimed them; turns and touchpoints are broadcast, and
12
+ // the first end settles one.
13
+ //
14
+ // Nothing here keeps the program alive, and nothing here lets it die
15
+ // mid-touchpoint: the server and its sockets are unref'd, so a program whose
16
+ // work is done exits with a harness still connected, while a pending
17
+ // decision, turn or touchpoint holds a ref'd timer of its own in the binding.
18
+ import { Buffer } from "node:buffer";
19
+ import { existsSync, unlinkSync } from "node:fs";
20
+ import { createServer } from "node:net";
21
+ import { tmpdir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+ import { CallError, } from "./harness-binding.js";
24
+ import { durationMs } from "./manifest/manifest.js";
25
+ import { assertSocketPathLength, attachCappedLineReader, DEFAULT_MAX_LINE_BYTES, defaultSocketPath, EXTENSIONS, ensureRunDir, MAX_SOCKET_PATH_BYTES, PROTOCOL_VERSION, removeDescriptor, writeDescriptor, } from "./wire/index.js";
26
+ /**
27
+ * Where the socket goes when the run directory's own path is too long for
28
+ * one (103 bytes on macOS, and a run directory under a deep project path
29
+ * gets there): a private directory under the machine's temp dir. The
30
+ * descriptor in the run directory names the socket wherever it is, which is
31
+ * what its `socket` field is for, so discovery is unchanged.
32
+ */
33
+ export function shortSocketPath(pid) {
34
+ // per user: the temp dir is shared on Linux, and the directory is made private to whoever creates it
35
+ const uid = typeof process.getuid === "function" ? String(process.getuid()) : "u";
36
+ return join(tmpdir(), `abai-sockets-${uid}`, `${pid}.sock`);
37
+ }
38
+ /** Unsent bytes a connection may hold before it is dropped: a harness that cannot keep up is not the program's problem. */
39
+ export const DEFAULT_MAX_BUFFERED = 16 * 1024 * 1024;
40
+ /** Consecutive claim timeouts after which a connection's claims are released: a harness that never answers holds nothing. */
41
+ export const CONSECUTIVE_TIMEOUTS_LIMIT = 3;
42
+ /** How long a socket gets to flush when the endpoint closes it. */
43
+ const CLOSE_GRACE_MS = 200;
44
+ function errorMessage(error) {
45
+ return error instanceof Error ? error.message : String(error);
46
+ }
47
+ function isRecord(value) {
48
+ return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ }
50
+ function line(value) {
51
+ return `${JSON.stringify(value)}\n`;
52
+ }
53
+ export class Endpoint {
54
+ runDir;
55
+ socketPath;
56
+ /** Resolves once the socket listens and the descriptor is written; rejects with why it could not. */
57
+ ready;
58
+ app;
59
+ maxBuffered;
60
+ maxLineBytes;
61
+ server;
62
+ connections = new Set();
63
+ claimants = new Map();
64
+ hooks = new Map();
65
+ /** Open turns and touchpoints, replayed to a connection that arrives while they are open. */
66
+ openTurns = new Map();
67
+ openTouchpoints = new Map();
68
+ connectionWaiters = [];
69
+ binding;
70
+ unsubscribe = [];
71
+ nextConnectionId = 1;
72
+ closed = false;
73
+ onExit;
74
+ settleReady;
75
+ constructor(app, options) {
76
+ this.app = app;
77
+ this.runDir = options.runDir;
78
+ const preferred = options.socketPath ?? defaultSocketPath(options.runDir, process.pid);
79
+ // an explicit path is used as given; the default falls back to a short one when it does not fit
80
+ this.socketPath =
81
+ options.socketPath === undefined && Buffer.byteLength(preferred) > MAX_SOCKET_PATH_BYTES
82
+ ? shortSocketPath(process.pid)
83
+ : preferred;
84
+ this.maxBuffered = options.maxBuffered ?? DEFAULT_MAX_BUFFERED;
85
+ this.maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
86
+ this.onExit = () => this.removeFiles();
87
+ this.ready = new Promise((resolve, reject) => {
88
+ this.settleReady = { resolve, reject };
89
+ });
90
+ this.listen();
91
+ }
92
+ resolveReady() {
93
+ const settle = this.settleReady;
94
+ this.settleReady = undefined;
95
+ settle?.resolve();
96
+ }
97
+ rejectReady(error) {
98
+ const settle = this.settleReady;
99
+ this.settleReady = undefined;
100
+ settle?.reject(error instanceof Error ? error : new Error(String(error)));
101
+ }
102
+ get connectionCount() {
103
+ return this.connections.size;
104
+ }
105
+ /** The ids of the open connections, in order of arrival. */
106
+ connectionIds() {
107
+ return [...this.connections].map((connection) => connection.id);
108
+ }
109
+ listen() {
110
+ try {
111
+ assertSocketPathLength(this.socketPath);
112
+ ensureRunDir(this.runDir);
113
+ if (this.socketPath !== defaultSocketPath(this.runDir, process.pid))
114
+ ensureRunDir(dirname(this.socketPath));
115
+ // a socket file left by an earlier life of this very pid number: nobody else writes to this name.
116
+ // (A second Abaigram under sidecar in the same process would take over the first one's socket here;
117
+ // one program is one endpoint, and a test suite that builds several keeps them in turns mode.)
118
+ if (existsSync(this.socketPath))
119
+ unlinkSync(this.socketPath);
120
+ }
121
+ catch (error) {
122
+ this.rejectReady(error);
123
+ return;
124
+ }
125
+ const server = createServer((socket) => this.accept(socket));
126
+ this.server = server;
127
+ server.once("error", (error) => this.rejectReady(error));
128
+ server.listen(this.socketPath, () => {
129
+ server.unref();
130
+ try {
131
+ writeDescriptor(this.runDir, {
132
+ pid: process.pid,
133
+ socket: this.socketPath,
134
+ cwd: process.cwd(),
135
+ name: this.app.name,
136
+ mode: this.app.mode,
137
+ startedAt: new Date().toISOString(),
138
+ });
139
+ }
140
+ catch (error) {
141
+ server.close();
142
+ this.rejectReady(error);
143
+ return;
144
+ }
145
+ process.once("exit", this.onExit);
146
+ this.resolveReady();
147
+ });
148
+ }
149
+ /** Resolves true as soon as a connection is open, or when one arrives within `timeoutMs`; false when none does. */
150
+ waitForConnection(timeoutMs) {
151
+ if (this.connections.size > 0)
152
+ return Promise.resolve(true);
153
+ return new Promise((resolve) => {
154
+ let settled = false;
155
+ const timer = setTimeout(() => {
156
+ if (settled)
157
+ return;
158
+ settled = true;
159
+ const index = this.connectionWaiters.indexOf(waiter);
160
+ if (index !== -1)
161
+ this.connectionWaiters.splice(index, 1);
162
+ resolve(false);
163
+ }, timeoutMs);
164
+ const waiter = () => {
165
+ if (settled)
166
+ return;
167
+ settled = true;
168
+ clearTimeout(timer);
169
+ resolve(true);
170
+ };
171
+ this.connectionWaiters.push(waiter);
172
+ });
173
+ }
174
+ /** Stop listening, drop every connection, remove the socket and the descriptor. */
175
+ close(reason = "the endpoint closed") {
176
+ if (this.closed)
177
+ return;
178
+ this.closed = true;
179
+ // closed before it listened: whoever awaits `ready` is told, rather than left waiting
180
+ this.rejectReady(new Error(reason));
181
+ for (const connection of [...this.connections])
182
+ this.drop(connection, reason);
183
+ this.server?.close();
184
+ this.removeFiles();
185
+ process.off("exit", this.onExit);
186
+ }
187
+ removeFiles() {
188
+ try {
189
+ removeDescriptor(this.runDir, process.pid);
190
+ }
191
+ catch {
192
+ // nothing to remove
193
+ }
194
+ try {
195
+ if (existsSync(this.socketPath))
196
+ unlinkSync(this.socketPath);
197
+ }
198
+ catch {
199
+ // a socket file that cannot be removed is pruned by the next listing
200
+ }
201
+ }
202
+ // -----------------------------------------------------------------------
203
+ // Connections
204
+ // -----------------------------------------------------------------------
205
+ accept(socket) {
206
+ if (this.closed) {
207
+ socket.destroy();
208
+ return;
209
+ }
210
+ // a connected harness never holds the program open; what holds it is the program's own pending work
211
+ socket.unref();
212
+ socket.setNoDelay?.(true);
213
+ const connection = {
214
+ id: `k${this.nextConnectionId++}`,
215
+ socket,
216
+ include: undefined,
217
+ exclude: new Set(),
218
+ claims: new Set(),
219
+ claimTimeoutMs: undefined,
220
+ consecutiveTimeouts: 0,
221
+ timeoutEpoch: 0,
222
+ detachReader: () => { },
223
+ };
224
+ let binding;
225
+ try {
226
+ binding = this.bindingFor(connection);
227
+ }
228
+ catch (error) {
229
+ // the program is attached some other way (an in-process harness): this connection cannot be served,
230
+ // and the one line saying so is flushed before the socket ends
231
+ socket.end(line({ type: "response", command: "hello", success: false, error: errorMessage(error), code: "error" }));
232
+ return;
233
+ }
234
+ socket.on("error", () => this.drop(connection, "the connection failed"));
235
+ socket.on("close", () => this.drop(connection, "the connection closed"));
236
+ connection.detachReader = attachCappedLineReader(socket, this.maxLineBytes, (text) => this.onLine(connection, text), () => this.drop(connection, `a line exceeded ${this.maxLineBytes} bytes`));
237
+ this.connections.add(connection);
238
+ this.write(connection, line(this.hello(connection, binding)));
239
+ // a harness that connects while the program waits at a touchpoint or a turn is told at once
240
+ for (const turn of this.openTurns.values())
241
+ this.write(connection, line(this.turnMessage(turn)));
242
+ for (const touchpoint of this.openTouchpoints.values()) {
243
+ this.write(connection, line(this.touchpointMessage(touchpoint)));
244
+ }
245
+ for (const waiter of this.connectionWaiters.splice(0))
246
+ waiter();
247
+ }
248
+ /** The one binding every connection shares, created by the first. Its own label is for acts no connection made. */
249
+ bindingFor(_connection) {
250
+ if (this.binding)
251
+ return this.binding;
252
+ const binding = this.app.bind("endpoint");
253
+ this.binding = binding;
254
+ this.unsubscribe = [
255
+ binding.on("event", (event) => this.broadcastEvent(event)),
256
+ binding.on("decision", (request) => this.onDecision(request)),
257
+ binding.on("decision_timeout", (id, name) => this.onDecisionTimeout(id, name)),
258
+ binding.on("turn", (request) => this.onTurn(request)),
259
+ binding.on("touchpoint", (request) => this.onTouchpoint(request)),
260
+ binding.on("detach", (reason) => this.onDetach(reason)),
261
+ ];
262
+ return binding;
263
+ }
264
+ hello(connection, binding) {
265
+ const { manifest } = this.app;
266
+ return {
267
+ type: "hello",
268
+ protocol: PROTOCOL_VERSION,
269
+ pid: process.pid,
270
+ cwd: process.cwd(),
271
+ mode: this.app.mode,
272
+ capabilities: {
273
+ hooks: manifest.decisions.filter((d) => d.policy !== "forbid").map((d) => d.name),
274
+ commands: manifest.capabilities.filter((c) => c.policy !== "forbid").map((c) => c.name),
275
+ },
276
+ lastSeq: this.app.region.lastSeq,
277
+ connection: connection.id,
278
+ abai: {
279
+ name: manifest.name,
280
+ abaigrammar: manifest.abaigrammar,
281
+ modes: [...manifest.modes],
282
+ audit: binding.auditInfo(),
283
+ extensions: [...EXTENSIONS],
284
+ },
285
+ };
286
+ }
287
+ write(connection, text) {
288
+ const { socket } = connection;
289
+ if (socket.destroyed || !socket.writable)
290
+ return;
291
+ socket.write(text);
292
+ if (socket.writableLength > this.maxBuffered)
293
+ this.drop(connection, "the connection fell too far behind");
294
+ }
295
+ drop(connection, reason) {
296
+ if (!this.connections.delete(connection))
297
+ return;
298
+ connection.detachReader();
299
+ for (const name of connection.claims) {
300
+ if (this.claimants.get(name) === connection)
301
+ this.claimants.delete(name);
302
+ }
303
+ connection.claims.clear();
304
+ // what this connection was asked, nobody will answer now
305
+ for (const hook of [...this.hooks.values()]) {
306
+ if (hook.connection !== connection)
307
+ continue;
308
+ this.forget(hook);
309
+ this.binding?.defaultDecision(hook.id, "detached", connection.id);
310
+ }
311
+ if (!connection.socket.destroyed) {
312
+ connection.socket.end();
313
+ const timer = setTimeout(() => connection.socket.destroy(), CLOSE_GRACE_MS);
314
+ timer.unref();
315
+ }
316
+ if (this.connections.size === 0 && this.binding) {
317
+ const binding = this.binding;
318
+ this.releaseBinding();
319
+ binding.detach(`the last harness connection closed (${reason})`);
320
+ }
321
+ }
322
+ /** Forget the binding and everything pending on it, every timer included: a finished program must be free to exit. */
323
+ releaseBinding() {
324
+ this.binding = undefined;
325
+ for (const off of this.unsubscribe.splice(0))
326
+ off();
327
+ for (const hook of [...this.hooks.values()])
328
+ this.forget(hook);
329
+ this.openTurns.clear();
330
+ this.openTouchpoints.clear();
331
+ }
332
+ /** The program detached the binding itself: whoever is still connected is told by the close. */
333
+ onDetach(_reason) {
334
+ if (!this.binding)
335
+ return;
336
+ this.releaseBinding();
337
+ for (const connection of [...this.connections])
338
+ this.drop(connection, "the program detached");
339
+ }
340
+ // -----------------------------------------------------------------------
341
+ // Lines in
342
+ // -----------------------------------------------------------------------
343
+ onLine(connection, text) {
344
+ if (text.trim() === "")
345
+ return;
346
+ let message;
347
+ try {
348
+ message = JSON.parse(text);
349
+ }
350
+ catch {
351
+ this.respond(connection, { type: "response", command: "unknown", success: false, error: "invalid json" });
352
+ return;
353
+ }
354
+ if (!isRecord(message)) {
355
+ this.respond(connection, { type: "response", command: "unknown", success: false, error: "invalid message" });
356
+ return;
357
+ }
358
+ const reason = typeof message.reason === "string" ? message.reason : undefined;
359
+ const consent = isConsent(message.consent) ? message.consent : undefined;
360
+ switch (message.type) {
361
+ case "request": {
362
+ const id = message.id;
363
+ if (typeof id !== "string" || id === "") {
364
+ this.respond(connection, {
365
+ type: "response",
366
+ command: "request",
367
+ success: false,
368
+ error: "request id must be a non-empty string",
369
+ code: "invalid",
370
+ });
371
+ return;
372
+ }
373
+ void this.dispatch(connection, id, message.command, reason, consent);
374
+ return;
375
+ }
376
+ case "hook_response":
377
+ this.onHookResponse(connection, message.id, message.result, reason, consent);
378
+ return;
379
+ case "turn_end":
380
+ if (typeof message.id === "string") {
381
+ this.openTurns.delete(message.id);
382
+ this.binding?.endTurn(message.id, connection.id, typeof message.cause === "string" ? message.cause : undefined);
383
+ }
384
+ return;
385
+ case "touchpoint_end":
386
+ if (typeof message.id === "string") {
387
+ this.openTouchpoints.delete(message.id);
388
+ this.binding?.endTouchpoint(message.id);
389
+ }
390
+ return;
391
+ default:
392
+ this.respond(connection, {
393
+ type: "response",
394
+ ...(typeof message.id === "string" ? { id: message.id } : {}),
395
+ command: "unknown",
396
+ success: false,
397
+ error: `unknown message type: ${String(message.type)}`,
398
+ code: "unknown",
399
+ });
400
+ }
401
+ }
402
+ respond(connection, response) {
403
+ try {
404
+ this.write(connection, line(response));
405
+ }
406
+ catch (error) {
407
+ // a result that cannot be serialized becomes an error response; the connection stays
408
+ const failed = {
409
+ type: "response",
410
+ ...(response.id === undefined ? {} : { id: response.id }),
411
+ command: response.command,
412
+ success: false,
413
+ error: `the response could not be serialized: ${errorMessage(error)}`,
414
+ code: "error",
415
+ };
416
+ this.write(connection, line(failed));
417
+ }
418
+ }
419
+ failure(id, command, error, code) {
420
+ return { type: "response", id, command, success: false, error, code };
421
+ }
422
+ /** Run one request, answer it when it settles. Never rejects. */
423
+ async dispatch(connection, id, command, reason, consent) {
424
+ if (!isRecord(command) || typeof command.type !== "string") {
425
+ this.respond(connection, this.failure(id, "unknown", "command must be an object with a type", "invalid"));
426
+ return;
427
+ }
428
+ const type = command.type;
429
+ const binding = this.binding;
430
+ if (!binding) {
431
+ this.respond(connection, this.failure(id, type, "the program is no longer attached", "error"));
432
+ return;
433
+ }
434
+ let response;
435
+ try {
436
+ switch (type) {
437
+ case "ping":
438
+ response = {
439
+ type: "response",
440
+ id,
441
+ command: type,
442
+ success: true,
443
+ data: { pid: process.pid, ts: Date.now() },
444
+ };
445
+ break;
446
+ case "subscribe":
447
+ response = this.subscribe(connection, id, command);
448
+ break;
449
+ case "claim_hooks":
450
+ response = this.claim(connection, id, command);
451
+ break;
452
+ case "release_hooks":
453
+ response = this.release(connection, id, command);
454
+ break;
455
+ case "get_manifest":
456
+ response = { type: "response", id, command: type, success: true, data: { manifest: this.app.manifest } };
457
+ break;
458
+ case "get_content": {
459
+ const content = binding.content();
460
+ const turns = {};
461
+ for (const turn of this.app.manifest.turns) {
462
+ const text = content.turn(turn.name);
463
+ if (text !== undefined)
464
+ turns[turn.name] = text;
465
+ }
466
+ response = {
467
+ type: "response",
468
+ id,
469
+ command: type,
470
+ success: true,
471
+ data: {
472
+ identity: content.identity,
473
+ ...(content.install === undefined ? {} : { install: content.install }),
474
+ ...(content.use === undefined ? {} : { use: content.use }),
475
+ turns,
476
+ },
477
+ };
478
+ break;
479
+ }
480
+ case "get_audit": {
481
+ const limit = typeof command.limit === "number" && command.limit > 0 ? Math.floor(command.limit) : 100;
482
+ response = {
483
+ type: "response",
484
+ id,
485
+ command: type,
486
+ success: true,
487
+ data: { ...binding.auditInfo(), ...binding.auditTail(limit) },
488
+ };
489
+ break;
490
+ }
491
+ case "get_cells": {
492
+ const reading = binding.readCells();
493
+ const cells = {};
494
+ for (const [name, cell] of Object.entries(reading.cells))
495
+ cells[name] = cell.value;
496
+ response = { type: "response", id, command: type, success: true, data: { seq: reading.seq, cells } };
497
+ break;
498
+ }
499
+ case "lifecycle": {
500
+ if (typeof command.op !== "string") {
501
+ response = this.failure(id, type, "lifecycle needs an op", "invalid");
502
+ break;
503
+ }
504
+ const outcome = binding.lifecycle(command.op, reason ?? "", consent, connection.id);
505
+ response = outcome.ok
506
+ ? {
507
+ type: "response",
508
+ id,
509
+ command: type,
510
+ success: true,
511
+ data: outcome.data ?? { op: command.op, done: true },
512
+ }
513
+ : this.failure(id, type, outcome.error, outcome.code);
514
+ break;
515
+ }
516
+ case "set_cell": {
517
+ if (typeof command.name !== "string") {
518
+ response = this.failure(id, type, "set_cell needs a cell name", "invalid");
519
+ break;
520
+ }
521
+ const outcome = await binding.writeKnob(command.name, command.value, reason ?? "", consent, connection.id);
522
+ response =
523
+ outcome.outcome === "done"
524
+ ? {
525
+ type: "response",
526
+ id,
527
+ command: type,
528
+ success: true,
529
+ data: { name: command.name, value: outcome.value },
530
+ }
531
+ : this.failure(id, type, outcome.error ?? `${command.name} was not written`, outcome.code ?? "error");
532
+ break;
533
+ }
534
+ default: {
535
+ const { type: _type, ...params } = command;
536
+ const result = await binding.call(type, params, reason ?? "", consent, connection.id);
537
+ response = { type: "response", id, command: type, success: true, data: result };
538
+ }
539
+ }
540
+ }
541
+ catch (error) {
542
+ const code = error instanceof CallError ? error.code : "error";
543
+ response = this.failure(id, type, errorMessage(error), code);
544
+ }
545
+ this.respond(connection, response);
546
+ }
547
+ subscribe(connection, id, command) {
548
+ const names = (value) => Array.isArray(value) && value.every((v) => typeof v === "string") ? value : undefined;
549
+ const include = command.include === undefined ? undefined : names(command.include);
550
+ const exclude = command.exclude === undefined ? [] : names(command.exclude);
551
+ if ((command.include !== undefined && include === undefined) || exclude === undefined) {
552
+ return this.failure(id, "subscribe", "include and exclude must be arrays of event names", "invalid");
553
+ }
554
+ connection.include = include === undefined ? undefined : new Set(include);
555
+ connection.exclude = new Set(exclude);
556
+ return {
557
+ type: "response",
558
+ id,
559
+ command: "subscribe",
560
+ success: true,
561
+ data: { ...(include === undefined ? {} : { include }), exclude },
562
+ };
563
+ }
564
+ // -----------------------------------------------------------------------
565
+ // Events
566
+ // -----------------------------------------------------------------------
567
+ accepts(connection, name) {
568
+ if (connection.include !== undefined && !connection.include.has(name))
569
+ return false;
570
+ return !connection.exclude.has(name);
571
+ }
572
+ broadcastEvent(event) {
573
+ const targets = [...this.connections].filter((connection) => this.accepts(connection, event.name));
574
+ if (targets.length === 0)
575
+ return;
576
+ let text;
577
+ try {
578
+ text = line({
579
+ type: "event",
580
+ seq: event.seq,
581
+ ts: event.ts,
582
+ event: { type: event.name, ...(event.payload ?? {}) },
583
+ ...(event.truncated ? { truncated: true } : {}),
584
+ });
585
+ }
586
+ catch (error) {
587
+ text = line({
588
+ type: "serialize_error",
589
+ seq: event.seq,
590
+ ts: event.ts,
591
+ eventType: event.name,
592
+ error: errorMessage(error),
593
+ });
594
+ }
595
+ for (const connection of targets)
596
+ this.write(connection, text);
597
+ }
598
+ // -----------------------------------------------------------------------
599
+ // Decisions as hooks
600
+ // -----------------------------------------------------------------------
601
+ claim(connection, id, command) {
602
+ const hooks = command.hooks;
603
+ const declared = new Map(this.app.manifest.decisions.map((d) => [d.name, d]));
604
+ if (!Array.isArray(hooks) || hooks.length === 0 || !hooks.every((h) => typeof h === "string")) {
605
+ return this.failure(id, "claim_hooks", "hooks must be a non-empty array of decision names", "invalid");
606
+ }
607
+ const timeoutMs = command.timeoutMs;
608
+ if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {
609
+ return this.failure(id, "claim_hooks", "timeoutMs must be a positive integer", "invalid");
610
+ }
611
+ // all or nothing: every name is checked before any is taken
612
+ for (const name of hooks) {
613
+ const decl = declared.get(name);
614
+ if (!decl)
615
+ return this.failure(id, "claim_hooks", `unknown decision: ${name}`, "unknown");
616
+ if (decl.policy === "forbid") {
617
+ return this.failure(id, "claim_hooks", `decision ${name} is forbidden by policy`, "forbidden");
618
+ }
619
+ const holder = this.claimants.get(name);
620
+ if (holder !== undefined && holder !== connection) {
621
+ return this.failure(id, "claim_hooks", `decision ${name} is held by connection ${holder.id}`, "denied");
622
+ }
623
+ }
624
+ for (const name of hooks) {
625
+ this.claimants.set(name, connection);
626
+ connection.claims.add(name);
627
+ }
628
+ // the wait in force: the claim's own, which may only shorten the shortest declared among what it holds
629
+ const shortest = Math.min(...[...connection.claims].map((name) => durationMs(declared.get(name)?.timeout ?? "30s")));
630
+ if (timeoutMs !== undefined)
631
+ connection.claimTimeoutMs = Math.min(timeoutMs, shortest);
632
+ return {
633
+ type: "response",
634
+ id,
635
+ command: "claim_hooks",
636
+ success: true,
637
+ data: { hooks: [...connection.claims], timeoutMs: Math.min(connection.claimTimeoutMs ?? shortest, shortest) },
638
+ };
639
+ }
640
+ release(connection, id, command) {
641
+ let names;
642
+ if (command.hooks === undefined)
643
+ names = [...connection.claims];
644
+ else if (Array.isArray(command.hooks) && command.hooks.every((h) => typeof h === "string")) {
645
+ names = command.hooks;
646
+ }
647
+ else
648
+ return this.failure(id, "release_hooks", "hooks must be an array of decision names", "invalid");
649
+ const released = this.releaseClaims(connection, names);
650
+ return { type: "response", id, command: "release_hooks", success: true, data: { released } };
651
+ }
652
+ /** Free some of a connection's claims; what it was asked under them takes the default now. */
653
+ releaseClaims(connection, names) {
654
+ const released = [];
655
+ for (const name of names) {
656
+ if (!connection.claims.has(name))
657
+ continue;
658
+ connection.claims.delete(name);
659
+ if (this.claimants.get(name) === connection)
660
+ this.claimants.delete(name);
661
+ released.push(name);
662
+ }
663
+ for (const hook of [...this.hooks.values()]) {
664
+ if (hook.connection !== connection || !released.includes(hook.name))
665
+ continue;
666
+ this.forget(hook);
667
+ // for this decision its claimant is gone, which is what detached means (section 5.4)
668
+ this.binding?.defaultDecision(hook.id, "detached", connection.id);
669
+ }
670
+ return released;
671
+ }
672
+ forget(hook) {
673
+ this.hooks.delete(hook.id);
674
+ if (hook.timer !== undefined)
675
+ clearTimeout(hook.timer);
676
+ }
677
+ /** The program asked a decision: it goes to the claimant, or takes its default now. */
678
+ onDecision(request) {
679
+ const binding = this.binding;
680
+ if (!binding)
681
+ return;
682
+ const connection = this.claimants.get(request.name);
683
+ if (!connection) {
684
+ binding.defaultDecision(request.id, "no_harness");
685
+ return;
686
+ }
687
+ binding.assignDecision(request.id, connection.id);
688
+ const remaining = Math.max(0, request.deadline - this.app.now());
689
+ const timeoutMs = connection.claimTimeoutMs === undefined ? remaining : Math.min(remaining, connection.claimTimeoutMs);
690
+ let text;
691
+ try {
692
+ text = line({ type: "hook", id: request.id, hook: request.name, timeoutMs, payload: request.context });
693
+ }
694
+ catch (error) {
695
+ // the context cannot cross: the claimant is told, and the program goes on with the default
696
+ const message = `payload could not be serialized: ${errorMessage(error)}`;
697
+ this.write(connection, line({ type: "hook_rejected", id: request.id, hook: request.name, error: message }));
698
+ binding.defaultDecision(request.id, "invalid", connection.id);
699
+ return;
700
+ }
701
+ const hook = {
702
+ id: request.id,
703
+ name: request.name,
704
+ connection,
705
+ epochAtSend: connection.timeoutEpoch,
706
+ timer: undefined,
707
+ };
708
+ if (timeoutMs < remaining) {
709
+ // the claim's own wait, shorter than the manifest's: the default is applied then, through the one path
710
+ hook.timer = setTimeout(() => binding.defaultDecision(request.id, "timeout", connection.id), timeoutMs);
711
+ }
712
+ this.hooks.set(request.id, hook);
713
+ this.write(connection, text);
714
+ }
715
+ /** The binding settled a decision by timeout (its deadline, or the claim's wait through `defaultDecision`). */
716
+ onDecisionTimeout(id, name) {
717
+ const hook = this.hooks.get(id);
718
+ if (!hook)
719
+ return;
720
+ this.forget(hook);
721
+ const { connection } = hook;
722
+ this.write(connection, line({ type: "hook_timeout", id, hook: name }));
723
+ // a batch that timed out together counts once
724
+ if (hook.epochAtSend === connection.timeoutEpoch)
725
+ connection.consecutiveTimeouts += 1;
726
+ connection.timeoutEpoch += 1;
727
+ if (connection.consecutiveTimeouts >= CONSECUTIVE_TIMEOUTS_LIMIT) {
728
+ connection.consecutiveTimeouts = 0;
729
+ const released = this.releaseClaims(connection, [...connection.claims]);
730
+ this.write(connection, line({ type: "hook_claims_released", reason: "timeouts", hooks: released }));
731
+ }
732
+ }
733
+ onHookResponse(connection, id, result, reason, consent) {
734
+ if (typeof id !== "string")
735
+ return;
736
+ const hook = this.hooks.get(id);
737
+ // ignored unless this very connection was asked: a late or a stray answer writes nothing
738
+ if (!hook || hook.connection !== connection)
739
+ return;
740
+ this.forget(hook);
741
+ try {
742
+ this.binding?.answerDecision(id, result ?? null, reason ?? "no reason stated by the harness", consent, connection.id);
743
+ // only an accepted answer resets the streak
744
+ connection.consecutiveTimeouts = 0;
745
+ }
746
+ catch (error) {
747
+ this.write(connection, line({ type: "hook_rejected", id, hook: hook.name, error: errorMessage(error) }));
748
+ }
749
+ }
750
+ // -----------------------------------------------------------------------
751
+ // Turns and touchpoints, broadcast
752
+ // -----------------------------------------------------------------------
753
+ turnMessage(request) {
754
+ return {
755
+ type: "turn",
756
+ id: request.id,
757
+ name: request.name,
758
+ timeoutMs: Math.max(0, request.deadline - this.app.now()),
759
+ ...(request.content === undefined ? {} : { content: request.content }),
760
+ };
761
+ }
762
+ touchpointMessage(request) {
763
+ return {
764
+ type: "touchpoint",
765
+ id: request.id,
766
+ kind: request.kind,
767
+ ...(request.content === undefined ? {} : { content: request.content }),
768
+ };
769
+ }
770
+ onTurn(request) {
771
+ this.openTurns.set(request.id, request);
772
+ // a turn ends on its own at its deadline, and this map must not remember it past that
773
+ const timer = setTimeout(() => this.openTurns.delete(request.id), Math.max(0, request.deadline - this.app.now()) + 1);
774
+ timer.unref();
775
+ const text = line(this.turnMessage(request));
776
+ for (const connection of [...this.connections])
777
+ this.write(connection, text);
778
+ }
779
+ onTouchpoint(request) {
780
+ this.openTouchpoints.set(request.id, request);
781
+ const text = line(this.touchpointMessage(request));
782
+ for (const connection of [...this.connections])
783
+ this.write(connection, text);
784
+ }
785
+ }
786
+ function isConsent(value) {
787
+ return value === "approved" || value === "auto" || value === "denied";
788
+ }
789
+ //# sourceMappingURL=endpoint.js.map