@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.
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/audit.d.ts +70 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +166 -0
- package/dist/audit.js.map +1 -0
- package/dist/cells/fit.d.ts +6 -0
- package/dist/cells/fit.d.ts.map +1 -0
- package/dist/cells/fit.js +44 -0
- package/dist/cells/fit.js.map +1 -0
- package/dist/content.d.ts +17 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +71 -0
- package/dist/content.js.map +1 -0
- package/dist/endpoint.d.ts +90 -0
- package/dist/endpoint.d.ts.map +1 -0
- package/dist/endpoint.js +789 -0
- package/dist/endpoint.js.map +1 -0
- package/dist/entry.d.ts +36 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +409 -0
- package/dist/entry.js.map +1 -0
- package/dist/env.d.ts +38 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +69 -0
- package/dist/env.js.map +1 -0
- package/dist/harness-binding.d.ts +223 -0
- package/dist/harness-binding.d.ts.map +1 -0
- package/dist/harness-binding.js +589 -0
- package/dist/harness-binding.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest/index.d.ts +3 -0
- package/dist/manifest/index.d.ts.map +1 -0
- package/dist/manifest/index.js +3 -0
- package/dist/manifest/index.js.map +1 -0
- package/dist/manifest/manifest.d.ts +172 -0
- package/dist/manifest/manifest.d.ts.map +1 -0
- package/dist/manifest/manifest.js +443 -0
- package/dist/manifest/manifest.js.map +1 -0
- package/dist/manifest/render.d.ts +6 -0
- package/dist/manifest/render.d.ts.map +1 -0
- package/dist/manifest/render.js +39 -0
- package/dist/manifest/render.js.map +1 -0
- package/dist/null-harness.d.ts +32 -0
- package/dist/null-harness.d.ts.map +1 -0
- package/dist/null-harness.js +162 -0
- package/dist/null-harness.js.map +1 -0
- package/dist/program.d.ts +250 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +877 -0
- package/dist/program.js.map +1 -0
- package/dist/region.d.ts +72 -0
- package/dist/region.d.ts.map +1 -0
- package/dist/region.js +92 -0
- package/dist/region.js.map +1 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +101 -0
- package/dist/registry.js.map +1 -0
- package/dist/wire/descriptor.d.ts +38 -0
- package/dist/wire/descriptor.d.ts.map +1 -0
- package/dist/wire/descriptor.js +144 -0
- package/dist/wire/descriptor.js.map +1 -0
- package/dist/wire/framing.d.ts +11 -0
- package/dist/wire/framing.d.ts.map +1 -0
- package/dist/wire/framing.js +42 -0
- package/dist/wire/framing.js.map +1 -0
- package/dist/wire/index.d.ts +4 -0
- package/dist/wire/index.d.ts.map +1 -0
- package/dist/wire/index.js +4 -0
- package/dist/wire/index.js.map +1 -0
- package/dist/wire/protocol.d.ts +255 -0
- package/dist/wire/protocol.d.ts.map +1 -0
- package/dist/wire/protocol.js +15 -0
- package/dist/wire/protocol.js.map +1 -0
- package/package.json +36 -5
package/dist/endpoint.js
ADDED
|
@@ -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
|