@haywoodmarx/agent-pager 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 +63 -0
- package/dist/src/broker/address.d.ts +49 -0
- package/dist/src/broker/address.js +110 -0
- package/dist/src/broker/broker.d.ts +665 -0
- package/dist/src/broker/broker.js +2270 -0
- package/dist/src/broker/database.d.ts +81 -0
- package/dist/src/broker/database.js +69 -0
- package/dist/src/broker/operator-projection.d.ts +291 -0
- package/dist/src/broker/operator-projection.js +722 -0
- package/dist/src/broker/protocol.d.ts +237 -0
- package/dist/src/broker/protocol.js +311 -0
- package/dist/src/broker/schema.d.ts +94 -0
- package/dist/src/broker/schema.js +454 -0
- package/dist/src/broker/snapshot.d.ts +171 -0
- package/dist/src/broker/snapshot.js +78 -0
- package/dist/src/configuration-defaults.d.ts +64 -0
- package/dist/src/configuration-defaults.js +48 -0
- package/dist/src/configuration.d.ts +76 -0
- package/dist/src/configuration.js +156 -0
- package/dist/src/implementations/claude/environment.d.ts +8 -0
- package/dist/src/implementations/claude/environment.js +9 -0
- package/dist/src/implementations/claude/gateway-cli.d.ts +2 -0
- package/dist/src/implementations/claude/gateway-cli.js +136 -0
- package/dist/src/implementations/claude/gateway-identity.d.ts +134 -0
- package/dist/src/implementations/claude/gateway-identity.js +103 -0
- package/dist/src/implementations/claude/gateway.d.ts +148 -0
- package/dist/src/implementations/claude/gateway.js +236 -0
- package/dist/src/implementations/claude/instructions.d.ts +33 -0
- package/dist/src/implementations/claude/instructions.js +64 -0
- package/dist/src/implementations/claude/manifest.d.ts +33 -0
- package/dist/src/implementations/claude/manifest.js +108 -0
- package/dist/src/implementations/claude/plugin-directory.d.ts +80 -0
- package/dist/src/implementations/claude/plugin-directory.js +137 -0
- package/dist/src/implementations/claude/session-continuation.d.ts +25 -0
- package/dist/src/implementations/claude/session-continuation.js +125 -0
- package/dist/src/implementations/claude/session-identity.d.ts +25 -0
- package/dist/src/implementations/claude/session-identity.js +31 -0
- package/dist/src/implementations/claude/session-start-cli.d.ts +2 -0
- package/dist/src/implementations/claude/session-start-cli.js +16 -0
- package/dist/src/implementations/claude/socket-delivery.d.ts +52 -0
- package/dist/src/implementations/claude/socket-delivery.js +159 -0
- package/dist/src/implementations/model.d.ts +162 -0
- package/dist/src/implementations/model.js +1 -0
- package/dist/src/implementations/opencode/adapter.d.ts +71 -0
- package/dist/src/implementations/opencode/adapter.js +94 -0
- package/dist/src/implementations/opencode/environment.d.ts +8 -0
- package/dist/src/implementations/opencode/environment.js +9 -0
- package/dist/src/implementations/opencode/manifest.d.ts +30 -0
- package/dist/src/implementations/opencode/manifest.js +93 -0
- package/dist/src/implementations/opencode/plugin-api.d.ts +120 -0
- package/dist/src/implementations/opencode/plugin-api.js +1 -0
- package/dist/src/implementations/opencode/plugin.d.ts +38 -0
- package/dist/src/implementations/opencode/plugin.js +303 -0
- package/dist/src/implementations/opencode/self-close-tool.d.ts +9 -0
- package/dist/src/implementations/opencode/self-close-tool.js +31 -0
- package/dist/src/implementations/orca/terminal.d.ts +48 -0
- package/dist/src/implementations/orca/terminal.js +154 -0
- package/dist/src/implementations/pi/broker-client.d.ts +56 -0
- package/dist/src/implementations/pi/broker-client.js +156 -0
- package/dist/src/implementations/pi/environment.d.ts +8 -0
- package/dist/src/implementations/pi/environment.js +9 -0
- package/dist/src/implementations/pi/extension.d.ts +57 -0
- package/dist/src/implementations/pi/extension.js +253 -0
- package/dist/src/implementations/pi/host-slots.d.ts +18 -0
- package/dist/src/implementations/pi/host-slots.js +27 -0
- package/dist/src/implementations/pi/manifest.d.ts +11 -0
- package/dist/src/implementations/pi/manifest.js +61 -0
- package/dist/src/implementations/pi/self-close-tool.d.ts +7 -0
- package/dist/src/implementations/pi/self-close-tool.js +27 -0
- package/dist/src/implementations/registry.d.ts +47 -0
- package/dist/src/implementations/registry.js +157 -0
- package/dist/src/implementations/session-key.d.ts +13 -0
- package/dist/src/implementations/session-key.js +19 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.js +26 -0
- package/dist/src/launch/broker-lock.d.ts +257 -0
- package/dist/src/launch/broker-lock.js +644 -0
- package/dist/src/launch/cli.d.ts +37 -0
- package/dist/src/launch/cli.js +317 -0
- package/dist/src/launch/environment.d.ts +45 -0
- package/dist/src/launch/environment.js +48 -0
- package/dist/src/launch/launch.d.ts +235 -0
- package/dist/src/launch/launch.js +223 -0
- package/dist/src/launch/on-demand.d.ts +202 -0
- package/dist/src/launch/on-demand.js +435 -0
- package/dist/src/launch/runtime-composition.d.ts +88 -0
- package/dist/src/launch/runtime-composition.js +253 -0
- package/dist/src/launch/runtime.d.ts +79 -0
- package/dist/src/launch/runtime.js +196 -0
- package/dist/src/launch/service-system.d.ts +11 -0
- package/dist/src/launch/service-system.js +39 -0
- package/dist/src/launch/service.d.ts +173 -0
- package/dist/src/launch/service.js +492 -0
- package/dist/src/launch/skill.d.ts +57 -0
- package/dist/src/launch/skill.js +81 -0
- package/dist/src/launch/terminal.d.ts +26 -0
- package/dist/src/launch/terminal.js +43 -0
- package/dist/src/launch/workspace-branch.d.ts +10 -0
- package/dist/src/launch/workspace-branch.js +29 -0
- package/dist/src/messaging/delivery-client.d.ts +144 -0
- package/dist/src/messaging/delivery-client.js +261 -0
- package/dist/src/messaging/delivery.d.ts +75 -0
- package/dist/src/messaging/delivery.js +22 -0
- package/dist/src/messaging/inbox-socket.d.ts +98 -0
- package/dist/src/messaging/inbox-socket.js +278 -0
- package/dist/src/messaging/mcp.d.ts +53 -0
- package/dist/src/messaging/mcp.js +501 -0
- package/dist/src/messaging/notifications.d.ts +231 -0
- package/dist/src/messaging/notifications.js +489 -0
- package/dist/src/messaging/push-delivery.d.ts +77 -0
- package/dist/src/messaging/push-delivery.js +187 -0
- package/dist/src/messaging/tools.d.ts +11 -0
- package/dist/src/messaging/tools.js +102 -0
- package/dist/src/messaging/wake.d.ts +23 -0
- package/dist/src/messaging/wake.js +39 -0
- package/dist/src/operator/agent-projection.d.ts +102 -0
- package/dist/src/operator/agent-projection.js +249 -0
- package/dist/src/operator/commands.d.ts +50 -0
- package/dist/src/operator/commands.js +199 -0
- package/dist/src/operator/diagnostics.d.ts +21 -0
- package/dist/src/operator/diagnostics.js +22 -0
- package/dist/src/operator/protocol.d.ts +511 -0
- package/dist/src/operator/protocol.js +49 -0
- package/dist/src/operator/repository-system.d.ts +14 -0
- package/dist/src/operator/repository-system.js +150 -0
- package/dist/src/operator/repository.d.ts +79 -0
- package/dist/src/operator/repository.js +72 -0
- package/dist/src/operator/traffic-projection.d.ts +19 -0
- package/dist/src/operator/traffic-projection.js +229 -0
- package/dist/src/operator/transport.d.ts +87 -0
- package/dist/src/operator/transport.js +549 -0
- package/dist/src/operator/update-store.d.ts +53 -0
- package/dist/src/operator/update-store.js +162 -0
- package/dist/src/operator/updates.d.ts +94 -0
- package/dist/src/operator/updates.js +280 -0
- package/dist/src/operator/validation.d.ts +114 -0
- package/dist/src/operator/validation.js +916 -0
- package/dist/src/shared/guards.d.ts +59 -0
- package/dist/src/shared/guards.js +119 -0
- package/dist/src/shared/http.d.ts +30 -0
- package/dist/src/shared/http.js +50 -0
- package/dist/src/shared/row.d.ts +2 -0
- package/dist/src/shared/row.js +1 -0
- package/dist/src/shared/shell.d.ts +6 -0
- package/dist/src/shared/shell.js +8 -0
- package/dist/src/tui/actions.d.ts +166 -0
- package/dist/src/tui/actions.js +253 -0
- package/dist/src/tui/agent-tree.d.ts +125 -0
- package/dist/src/tui/agent-tree.js +663 -0
- package/dist/src/tui/app.d.ts +38 -0
- package/dist/src/tui/app.js +378 -0
- package/dist/src/tui/chrome.d.ts +86 -0
- package/dist/src/tui/chrome.js +85 -0
- package/dist/src/tui/client.d.ts +41 -0
- package/dist/src/tui/client.js +279 -0
- package/dist/src/tui/composition.d.ts +41 -0
- package/dist/src/tui/composition.js +146 -0
- package/dist/src/tui/message-views.d.ts +94 -0
- package/dist/src/tui/message-views.js +515 -0
- package/dist/src/tui/model.d.ts +414 -0
- package/dist/src/tui/model.js +758 -0
- package/dist/src/tui/presentation.d.ts +350 -0
- package/dist/src/tui/presentation.js +541 -0
- package/dist/src/tui/production.d.ts +79 -0
- package/dist/src/tui/production.js +253 -0
- package/dist/src/tui/run-terminal.d.ts +32 -0
- package/dist/src/tui/run-terminal.js +54 -0
- package/dist/src/tui/runtime.d.ts +113 -0
- package/dist/src/tui/runtime.js +189 -0
- package/dist/src/tui/theme.d.ts +49 -0
- package/dist/src/tui/theme.js +55 -0
- package/dist/src/tui/transport.d.ts +31 -0
- package/dist/src/tui/transport.js +255 -0
- package/dist/src/version.d.ts +11 -0
- package/dist/src/version.js +52 -0
- package/package.json +142 -0
- package/skills/pager/SKILL.md +112 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { LOOPBACK_HOST } from "../configuration-defaults.js";
|
|
5
|
+
import { closeServer, listenServer, urlHost } from "../shared/http.js";
|
|
6
|
+
import { OPERATOR_PROTOCOL_VERSION, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, } from "./protocol.js";
|
|
7
|
+
import { OperatorValidationError, validateOperatorCommandResultForCommand, validateOperatorSnapshotByteLength, validateOperatorTransportRequest, } from "./validation.js";
|
|
8
|
+
const INPUT_LIMIT_BYTES = 64 * 1_024;
|
|
9
|
+
const LIVE_QUEUE_LIMIT = 32;
|
|
10
|
+
const DIAGNOSTIC_LIMIT = 256;
|
|
11
|
+
const DEFAULT_MAX_CONNECTIONS = 64;
|
|
12
|
+
const CLIENT_ID_LIMIT = 64;
|
|
13
|
+
/** Bounds distinct serialized snapshot versions retained by active transfers. */
|
|
14
|
+
const OPERATOR_MAX_CONCURRENT_SNAPSHOT_TRANSFERS = 2;
|
|
15
|
+
/**
|
|
16
|
+
* Start a loopback-only newline-delimited operator stream.
|
|
17
|
+
* @param updates - Authoritative snapshot, replay, and publication service.
|
|
18
|
+
* @param options - Explicit bind, clock, identity, scheduler, and liveness policy.
|
|
19
|
+
* @returns Bound transport handle.
|
|
20
|
+
*/
|
|
21
|
+
export async function startOperatorTransport(updates, options) {
|
|
22
|
+
const resolvedOptions = {
|
|
23
|
+
...options,
|
|
24
|
+
handshakeTimeoutMs: options.handshakeTimeoutMs ?? options.livenessTimeoutMs,
|
|
25
|
+
maxConnections: options.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
|
|
26
|
+
maxSnapshotTransfers: options.maxSnapshotTransfers ?? OPERATOR_MAX_CONCURRENT_SNAPSHOT_TRANSFERS,
|
|
27
|
+
};
|
|
28
|
+
validateOptions(resolvedOptions);
|
|
29
|
+
const diagnostics = [];
|
|
30
|
+
const connections = new Set();
|
|
31
|
+
const snapshotTransfers = new SnapshotTransferPool(resolvedOptions.maxSnapshotTransfers);
|
|
32
|
+
let clientSequence = 0;
|
|
33
|
+
const record = (type, clientId) => {
|
|
34
|
+
diagnostics.push({ type, clientId, at: resolvedOptions.now() });
|
|
35
|
+
if (diagnostics.length > DIAGNOSTIC_LIMIT)
|
|
36
|
+
diagnostics.shift();
|
|
37
|
+
};
|
|
38
|
+
const nextClientId = () => {
|
|
39
|
+
clientSequence = clientSequence === Number.MAX_SAFE_INTEGER ? 1 : clientSequence + 1;
|
|
40
|
+
return safeClientId(resolvedOptions.issueClientId, clientSequence);
|
|
41
|
+
};
|
|
42
|
+
const server = createServer((socket) => {
|
|
43
|
+
const clientId = nextClientId();
|
|
44
|
+
if (connections.size >= resolvedOptions.maxConnections) {
|
|
45
|
+
record("connection_rejected", clientId);
|
|
46
|
+
socket.destroy();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const connection = new OperatorConnection({
|
|
50
|
+
socket,
|
|
51
|
+
updates,
|
|
52
|
+
snapshotTransfers,
|
|
53
|
+
options: resolvedOptions,
|
|
54
|
+
clientId,
|
|
55
|
+
record,
|
|
56
|
+
onClose: () => connections.delete(connection),
|
|
57
|
+
});
|
|
58
|
+
connections.add(connection);
|
|
59
|
+
});
|
|
60
|
+
await listenServer(server, resolvedOptions.port, resolvedOptions.host);
|
|
61
|
+
const cancelPulse = resolvedOptions.scheduler.recurring(() => {
|
|
62
|
+
for (const connection of connections)
|
|
63
|
+
connection.pulse();
|
|
64
|
+
}, resolvedOptions.heartbeatIntervalMs);
|
|
65
|
+
const address = server.address();
|
|
66
|
+
if (!address || typeof address === "string") {
|
|
67
|
+
cancelPulse();
|
|
68
|
+
server.close();
|
|
69
|
+
throw new Error("operator transport did not publish a TCP address");
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
url: `tcp://${urlHost(address.address)}:${String(address.port)}`,
|
|
73
|
+
diagnostics: () => diagnostics.map((entry) => ({ ...entry })),
|
|
74
|
+
pulse: () => {
|
|
75
|
+
for (const connection of connections)
|
|
76
|
+
connection.pulse();
|
|
77
|
+
},
|
|
78
|
+
close: async () => {
|
|
79
|
+
cancelPulse();
|
|
80
|
+
for (const connection of [...connections])
|
|
81
|
+
connection.destroy();
|
|
82
|
+
await closeServer(server);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Issue one bounded public-safe transport diagnostic identity.
|
|
88
|
+
* @returns An opaque identity that contains no network address or process binding.
|
|
89
|
+
*/
|
|
90
|
+
export function issueOperatorClientId() {
|
|
91
|
+
return `operator-client-${randomBytes(12).toString("hex")}`;
|
|
92
|
+
}
|
|
93
|
+
class SnapshotTransferPool {
|
|
94
|
+
#maxTransfers;
|
|
95
|
+
#entries = new Map();
|
|
96
|
+
constructor(maxTransfers) {
|
|
97
|
+
this.#maxTransfers = maxTransfers;
|
|
98
|
+
}
|
|
99
|
+
acquire(snapshot, mode) {
|
|
100
|
+
const key = snapshotTransferKey(snapshot.cursor);
|
|
101
|
+
const existing = this.#entries.get(key);
|
|
102
|
+
if (existing) {
|
|
103
|
+
existing.references += 1;
|
|
104
|
+
return { type: "acquired", transfer: { shared: existing, mode, nextFrame: "begin" } };
|
|
105
|
+
}
|
|
106
|
+
for (const [entryKey, entry] of this.#entries) {
|
|
107
|
+
if (entry.references === 0)
|
|
108
|
+
this.#entries.delete(entryKey);
|
|
109
|
+
}
|
|
110
|
+
if (this.#entries.size >= this.#maxTransfers)
|
|
111
|
+
return { type: "unavailable" };
|
|
112
|
+
let bytes;
|
|
113
|
+
try {
|
|
114
|
+
bytes = Buffer.from(JSON.stringify(snapshot), "utf8");
|
|
115
|
+
validateOperatorSnapshotByteLength(bytes.byteLength);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { type: "unavailable" };
|
|
119
|
+
}
|
|
120
|
+
const entry = {
|
|
121
|
+
key,
|
|
122
|
+
cursor: { ...snapshot.cursor },
|
|
123
|
+
bytes,
|
|
124
|
+
chunkCount: Math.ceil(bytes.byteLength / OPERATOR_SNAPSHOT_CHUNK_BYTES),
|
|
125
|
+
references: 1,
|
|
126
|
+
};
|
|
127
|
+
this.#entries.set(key, entry);
|
|
128
|
+
return { type: "acquired", transfer: { shared: entry, mode, nextFrame: "begin" } };
|
|
129
|
+
}
|
|
130
|
+
release(transfer) {
|
|
131
|
+
const entry = this.#entries.get(transfer.shared.key);
|
|
132
|
+
if (entry !== transfer.shared || entry.references < 1)
|
|
133
|
+
return;
|
|
134
|
+
entry.references -= 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
class OperatorConnection {
|
|
138
|
+
#socket;
|
|
139
|
+
#decoder = new StringDecoder("utf8");
|
|
140
|
+
#updates;
|
|
141
|
+
#snapshotTransfers;
|
|
142
|
+
#options;
|
|
143
|
+
#record;
|
|
144
|
+
#onClose;
|
|
145
|
+
#clientId;
|
|
146
|
+
#buffer = "";
|
|
147
|
+
#subscribed = false;
|
|
148
|
+
#closed = false;
|
|
149
|
+
#blocked = false;
|
|
150
|
+
#closeAfterFlush = false;
|
|
151
|
+
#prelude = [];
|
|
152
|
+
#pending = [];
|
|
153
|
+
#snapshotTransfer;
|
|
154
|
+
#unsubscribe;
|
|
155
|
+
#lastAckAt;
|
|
156
|
+
#lastAckCursor;
|
|
157
|
+
#lastSentCursor;
|
|
158
|
+
constructor({ socket, updates, snapshotTransfers, options, clientId, record, onClose, }) {
|
|
159
|
+
this.#socket = socket;
|
|
160
|
+
this.#updates = updates;
|
|
161
|
+
this.#snapshotTransfers = snapshotTransfers;
|
|
162
|
+
this.#options = options;
|
|
163
|
+
this.#record = record;
|
|
164
|
+
this.#onClose = onClose;
|
|
165
|
+
this.#clientId = clientId;
|
|
166
|
+
this.#lastAckAt = options.now();
|
|
167
|
+
this.#socket.setEncoding("utf8");
|
|
168
|
+
this.#socket.on("data", (chunk) => {
|
|
169
|
+
this.#receive(typeof chunk === "string" ? chunk : this.#decoder.write(chunk));
|
|
170
|
+
});
|
|
171
|
+
this.#socket.on("drain", () => {
|
|
172
|
+
this.#drain();
|
|
173
|
+
});
|
|
174
|
+
this.#socket.on("error", () => {
|
|
175
|
+
this.destroy();
|
|
176
|
+
});
|
|
177
|
+
this.#socket.on("close", () => {
|
|
178
|
+
this.#closedBySocket();
|
|
179
|
+
});
|
|
180
|
+
this.#record("connected", this.#clientId);
|
|
181
|
+
}
|
|
182
|
+
pulse() {
|
|
183
|
+
if (this.#closed)
|
|
184
|
+
return;
|
|
185
|
+
if (!this.#subscribed) {
|
|
186
|
+
if (this.#options.now() - this.#lastAckAt >= this.#options.handshakeTimeoutMs) {
|
|
187
|
+
this.#record("handshake_timeout", this.#clientId);
|
|
188
|
+
this.destroy();
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (this.#options.now() - this.#lastAckAt >= this.#options.livenessTimeoutMs) {
|
|
193
|
+
this.#record("liveness_timeout", this.#clientId);
|
|
194
|
+
this.destroy();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (this.#blocked || this.#pending.length > 0) {
|
|
198
|
+
this.#record("slow_consumer", this.#clientId);
|
|
199
|
+
this.destroy();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
this.#enqueue({ type: "heartbeat", cursor: this.#updates.cursor(), sentAt: this.#options.now() }, false);
|
|
203
|
+
this.#record("heartbeat_sent", this.#clientId);
|
|
204
|
+
}
|
|
205
|
+
destroy() {
|
|
206
|
+
if (this.#closed)
|
|
207
|
+
return;
|
|
208
|
+
this.#closed = true;
|
|
209
|
+
this.#unsubscribe?.();
|
|
210
|
+
this.#unsubscribe = undefined;
|
|
211
|
+
this.#releaseSnapshotTransfer();
|
|
212
|
+
this.#prelude = [];
|
|
213
|
+
this.#pending = [];
|
|
214
|
+
this.#socket.destroy();
|
|
215
|
+
}
|
|
216
|
+
#receive(chunk) {
|
|
217
|
+
if (this.#closed)
|
|
218
|
+
return;
|
|
219
|
+
this.#buffer += chunk;
|
|
220
|
+
if (Buffer.byteLength(this.#buffer, "utf8") > INPUT_LIMIT_BYTES) {
|
|
221
|
+
this.#malformed();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (;;) {
|
|
225
|
+
const newline = this.#buffer.indexOf("\n");
|
|
226
|
+
if (newline < 0)
|
|
227
|
+
return;
|
|
228
|
+
const line = this.#buffer.slice(0, newline);
|
|
229
|
+
this.#buffer = this.#buffer.slice(newline + 1);
|
|
230
|
+
if (!line || Buffer.byteLength(line, "utf8") > INPUT_LIMIT_BYTES) {
|
|
231
|
+
this.#malformed();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
let value;
|
|
235
|
+
try {
|
|
236
|
+
value = JSON.parse(line);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
this.#malformed();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!this.#accept(value))
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
#accept(value) {
|
|
247
|
+
const version = transportVersion(value);
|
|
248
|
+
if (version !== undefined && version !== OPERATOR_PROTOCOL_VERSION) {
|
|
249
|
+
this.#record("incompatible_version", this.#clientId);
|
|
250
|
+
this.#enqueue({
|
|
251
|
+
type: "incompatible_version",
|
|
252
|
+
supportedVersion: OPERATOR_PROTOCOL_VERSION,
|
|
253
|
+
receivedVersion: version,
|
|
254
|
+
}, false);
|
|
255
|
+
this.#finishAfterFlush();
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
let request;
|
|
259
|
+
try {
|
|
260
|
+
request = validateOperatorTransportRequest(value);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (error instanceof OperatorValidationError) {
|
|
264
|
+
this.#malformed();
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
if (request.type === "subscribe")
|
|
270
|
+
return this.#subscribe(request);
|
|
271
|
+
if (request.type === "ack")
|
|
272
|
+
return this.#acknowledge(request);
|
|
273
|
+
return this.#command(request);
|
|
274
|
+
}
|
|
275
|
+
#subscribe(request) {
|
|
276
|
+
if (this.#subscribed) {
|
|
277
|
+
this.#malformed();
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
this.#subscribed = true;
|
|
281
|
+
const opened = this.#updates.open(request.cursor ?? null, (publication) => {
|
|
282
|
+
this.#publish(publication);
|
|
283
|
+
});
|
|
284
|
+
this.#unsubscribe = opened.unsubscribe;
|
|
285
|
+
this.#record("subscribed", this.#clientId);
|
|
286
|
+
switch (opened.result.type) {
|
|
287
|
+
case "initial":
|
|
288
|
+
if (!this.#beginSnapshotTransfer(opened.result.snapshot, "initial"))
|
|
289
|
+
return false;
|
|
290
|
+
break;
|
|
291
|
+
case "resumed":
|
|
292
|
+
for (const envelope of opened.result.events) {
|
|
293
|
+
this.#pending.push({ type: "event", envelope });
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
case "replacement":
|
|
297
|
+
if (!this.#beginSnapshotTransfer(opened.result.snapshot, "replacement"))
|
|
298
|
+
return false;
|
|
299
|
+
this.#record("resync_required", this.#clientId);
|
|
300
|
+
this.#prelude.push({ type: "resync_required", reason: opened.result.reason });
|
|
301
|
+
break;
|
|
302
|
+
case "unavailable":
|
|
303
|
+
this.#makeStreamUnavailable();
|
|
304
|
+
this.#flush();
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
this.#pending.push({ type: "heartbeat", cursor: this.#updates.cursor(), sentAt: this.#options.now() });
|
|
308
|
+
this.#flush();
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
#acknowledge(request) {
|
|
312
|
+
if (!this.#subscribed || !this.#lastSentCursor) {
|
|
313
|
+
this.#malformed();
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
const cursor = request.cursor;
|
|
317
|
+
if (cursor.epoch !== this.#lastSentCursor.epoch ||
|
|
318
|
+
cursor.sequence > this.#lastSentCursor.sequence ||
|
|
319
|
+
(this.#lastAckCursor && cursor.sequence < this.#lastAckCursor.sequence)) {
|
|
320
|
+
this.#malformed();
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
this.#lastAckCursor = { ...cursor };
|
|
324
|
+
this.#lastAckAt = this.#options.now();
|
|
325
|
+
this.#record("liveness_acknowledged", this.#clientId);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
#command(request) {
|
|
329
|
+
if (!this.#subscribed) {
|
|
330
|
+
this.#malformed();
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
if (this.#blocked && this.#pending.length >= LIVE_QUEUE_LIMIT) {
|
|
334
|
+
this.#record("slow_consumer", this.#clientId);
|
|
335
|
+
this.destroy();
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
const result = this.#options.commands.execute(request.command);
|
|
340
|
+
const correlated = validateOperatorCommandResultForCommand(request.command, result);
|
|
341
|
+
this.#enqueue({ type: "command_result", result: correlated.result }, false);
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
this.#malformed();
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
#publish(publication) {
|
|
350
|
+
if (publication.type === "diagnostic") {
|
|
351
|
+
this.#enqueue({ type: "diagnostic", diagnostic: publication.diagnostic }, true);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (publication.type === "unavailable") {
|
|
355
|
+
this.#record("resync_required", this.#clientId);
|
|
356
|
+
this.#enqueue({ type: "resync_required", reason: "stream_unavailable" }, true);
|
|
357
|
+
this.#finishAfterFlush();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
this.#enqueue({ type: "event", envelope: publication.envelope }, true);
|
|
361
|
+
}
|
|
362
|
+
#enqueue(frame, live) {
|
|
363
|
+
if (this.#closed)
|
|
364
|
+
return;
|
|
365
|
+
if (live && this.#pending.length >= LIVE_QUEUE_LIMIT) {
|
|
366
|
+
this.#record("slow_consumer", this.#clientId);
|
|
367
|
+
this.destroy();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
this.#pending.push(frame);
|
|
371
|
+
this.#flush();
|
|
372
|
+
}
|
|
373
|
+
#flush() {
|
|
374
|
+
if (this.#blocked || this.#closed)
|
|
375
|
+
return;
|
|
376
|
+
for (;;) {
|
|
377
|
+
const frame = this.#nextFrame();
|
|
378
|
+
if (!frame)
|
|
379
|
+
break;
|
|
380
|
+
const encoded = `${JSON.stringify(frame)}\n`;
|
|
381
|
+
if (Buffer.byteLength(encoded, "utf8") > OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES) {
|
|
382
|
+
this.#makeStreamUnavailable();
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
this.#observeSentCursor(frame);
|
|
386
|
+
if (!this.#socket.write(encoded)) {
|
|
387
|
+
this.#blocked = true;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (this.#closeAfterFlush)
|
|
392
|
+
this.#socket.end();
|
|
393
|
+
}
|
|
394
|
+
#drain() {
|
|
395
|
+
if (this.#closed)
|
|
396
|
+
return;
|
|
397
|
+
this.#blocked = false;
|
|
398
|
+
this.#flush();
|
|
399
|
+
}
|
|
400
|
+
#observeSentCursor(frame) {
|
|
401
|
+
if (frame.type === "snapshot_end")
|
|
402
|
+
this.#lastSentCursor = { ...frame.cursor };
|
|
403
|
+
if (frame.type === "event") {
|
|
404
|
+
this.#lastSentCursor = {
|
|
405
|
+
protocolVersion: OPERATOR_PROTOCOL_VERSION,
|
|
406
|
+
epoch: frame.envelope.epoch,
|
|
407
|
+
sequence: frame.envelope.sequence,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (frame.type === "heartbeat")
|
|
411
|
+
this.#lastSentCursor = { ...frame.cursor };
|
|
412
|
+
}
|
|
413
|
+
#finishAfterFlush() {
|
|
414
|
+
this.#unsubscribe?.();
|
|
415
|
+
this.#unsubscribe = undefined;
|
|
416
|
+
this.#closeAfterFlush = true;
|
|
417
|
+
this.#flush();
|
|
418
|
+
}
|
|
419
|
+
#beginSnapshotTransfer(snapshot, mode) {
|
|
420
|
+
const acquired = this.#snapshotTransfers.acquire(snapshot, mode);
|
|
421
|
+
if (acquired.type === "unavailable") {
|
|
422
|
+
this.#makeStreamUnavailable();
|
|
423
|
+
this.#flush();
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
this.#snapshotTransfer = acquired.transfer;
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
#nextFrame() {
|
|
430
|
+
const prelude = this.#prelude.shift();
|
|
431
|
+
if (prelude)
|
|
432
|
+
return prelude;
|
|
433
|
+
const transfer = this.#snapshotTransfer;
|
|
434
|
+
if (transfer) {
|
|
435
|
+
if (transfer.nextFrame === "begin") {
|
|
436
|
+
transfer.nextFrame = 0;
|
|
437
|
+
return {
|
|
438
|
+
type: "snapshot_begin",
|
|
439
|
+
protocolVersion: OPERATOR_PROTOCOL_VERSION,
|
|
440
|
+
mode: transfer.mode,
|
|
441
|
+
cursor: { ...transfer.shared.cursor },
|
|
442
|
+
encoding: "base64",
|
|
443
|
+
totalBytes: transfer.shared.bytes.byteLength,
|
|
444
|
+
chunkCount: transfer.shared.chunkCount,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (typeof transfer.nextFrame === "number") {
|
|
448
|
+
const index = transfer.nextFrame;
|
|
449
|
+
if (index < transfer.shared.chunkCount) {
|
|
450
|
+
const start = index * OPERATOR_SNAPSHOT_CHUNK_BYTES;
|
|
451
|
+
const end = Math.min(start + OPERATOR_SNAPSHOT_CHUNK_BYTES, transfer.shared.bytes.byteLength);
|
|
452
|
+
transfer.nextFrame = index + 1 < transfer.shared.chunkCount ? index + 1 : "end";
|
|
453
|
+
return {
|
|
454
|
+
type: "snapshot_chunk",
|
|
455
|
+
protocolVersion: OPERATOR_PROTOCOL_VERSION,
|
|
456
|
+
index,
|
|
457
|
+
data: transfer.shared.bytes.toString("base64", start, end),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const frame = {
|
|
462
|
+
type: "snapshot_end",
|
|
463
|
+
protocolVersion: OPERATOR_PROTOCOL_VERSION,
|
|
464
|
+
cursor: { ...transfer.shared.cursor },
|
|
465
|
+
totalBytes: transfer.shared.bytes.byteLength,
|
|
466
|
+
chunkCount: transfer.shared.chunkCount,
|
|
467
|
+
};
|
|
468
|
+
this.#releaseSnapshotTransfer();
|
|
469
|
+
return frame;
|
|
470
|
+
}
|
|
471
|
+
return this.#pending.shift();
|
|
472
|
+
}
|
|
473
|
+
#releaseSnapshotTransfer() {
|
|
474
|
+
if (!this.#snapshotTransfer)
|
|
475
|
+
return;
|
|
476
|
+
this.#snapshotTransfers.release(this.#snapshotTransfer);
|
|
477
|
+
this.#snapshotTransfer = undefined;
|
|
478
|
+
}
|
|
479
|
+
#makeStreamUnavailable() {
|
|
480
|
+
this.#releaseSnapshotTransfer();
|
|
481
|
+
this.#prelude = [{ type: "resync_required", reason: "stream_unavailable" }];
|
|
482
|
+
this.#pending = [];
|
|
483
|
+
this.#record("stream_unavailable", this.#clientId);
|
|
484
|
+
this.#record("resync_required", this.#clientId);
|
|
485
|
+
this.#unsubscribe?.();
|
|
486
|
+
this.#unsubscribe = undefined;
|
|
487
|
+
this.#closeAfterFlush = true;
|
|
488
|
+
}
|
|
489
|
+
#malformed() {
|
|
490
|
+
this.#record("malformed_request", this.#clientId);
|
|
491
|
+
this.destroy();
|
|
492
|
+
}
|
|
493
|
+
#closedBySocket() {
|
|
494
|
+
if (!this.#closed) {
|
|
495
|
+
this.#closed = true;
|
|
496
|
+
this.#unsubscribe?.();
|
|
497
|
+
this.#unsubscribe = undefined;
|
|
498
|
+
this.#releaseSnapshotTransfer();
|
|
499
|
+
this.#prelude = [];
|
|
500
|
+
this.#pending = [];
|
|
501
|
+
}
|
|
502
|
+
this.#record("disconnected", this.#clientId);
|
|
503
|
+
this.#onClose();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function transportVersion(value) {
|
|
507
|
+
if (typeof value !== "object" || value === null || !("protocolVersion" in value))
|
|
508
|
+
return undefined;
|
|
509
|
+
const version = value.protocolVersion;
|
|
510
|
+
return typeof version === "number" && Number.isSafeInteger(version) ? version : undefined;
|
|
511
|
+
}
|
|
512
|
+
function validateOptions(options) {
|
|
513
|
+
if (options.host !== LOOPBACK_HOST && options.host !== "::1")
|
|
514
|
+
throw new Error("operator transport must bind loopback");
|
|
515
|
+
if (!Number.isSafeInteger(options.port) || options.port < 0 || options.port > 65_535) {
|
|
516
|
+
throw new Error("operator transport port is invalid");
|
|
517
|
+
}
|
|
518
|
+
if (!Number.isSafeInteger(options.heartbeatIntervalMs) || options.heartbeatIntervalMs < 1) {
|
|
519
|
+
throw new Error("operator heartbeat interval is invalid");
|
|
520
|
+
}
|
|
521
|
+
if (!Number.isSafeInteger(options.livenessTimeoutMs) || options.livenessTimeoutMs <= options.heartbeatIntervalMs) {
|
|
522
|
+
throw new Error("operator liveness timeout must exceed its heartbeat interval");
|
|
523
|
+
}
|
|
524
|
+
if (!Number.isSafeInteger(options.handshakeTimeoutMs) || options.handshakeTimeoutMs < 1) {
|
|
525
|
+
throw new Error("operator handshake timeout is invalid");
|
|
526
|
+
}
|
|
527
|
+
if (!Number.isSafeInteger(options.maxConnections) || options.maxConnections < 1) {
|
|
528
|
+
throw new Error("operator connection limit is invalid");
|
|
529
|
+
}
|
|
530
|
+
if (!Number.isSafeInteger(options.maxSnapshotTransfers) || options.maxSnapshotTransfers < 1) {
|
|
531
|
+
throw new Error("operator snapshot transfer limit is invalid");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function snapshotTransferKey(cursor) {
|
|
535
|
+
return JSON.stringify(cursor);
|
|
536
|
+
}
|
|
537
|
+
function safeClientId(issueClientId, sequence) {
|
|
538
|
+
let candidate;
|
|
539
|
+
try {
|
|
540
|
+
candidate = issueClientId();
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
candidate = undefined;
|
|
544
|
+
}
|
|
545
|
+
if (candidate && candidate.length <= CLIENT_ID_LIMIT && /^[a-z0-9][a-z0-9_-]*$/iu.test(candidate)) {
|
|
546
|
+
return candidate;
|
|
547
|
+
}
|
|
548
|
+
return `operator-client-fallback-${String(sequence)}`;
|
|
549
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type BrokerDatabase } from "../broker/database.js";
|
|
2
|
+
import { type OperatorCursor, type OperatorEventEnvelope, type OperatorStateEvent } from "./protocol.js";
|
|
3
|
+
/** Reports resumable events or the exact replacement condition. */
|
|
4
|
+
export type OperatorReplayResult = {
|
|
5
|
+
type: "events";
|
|
6
|
+
events: OperatorEventEnvelope[];
|
|
7
|
+
} | {
|
|
8
|
+
type: "epoch_changed";
|
|
9
|
+
} | {
|
|
10
|
+
type: "cursor_gap";
|
|
11
|
+
};
|
|
12
|
+
/** Persists complete event batches before any client can observe their cursors. */
|
|
13
|
+
export interface OperatorEventStorePort {
|
|
14
|
+
/**
|
|
15
|
+
* Return the latest durable stream position.
|
|
16
|
+
* @returns The current epoch-scoped cursor.
|
|
17
|
+
*/
|
|
18
|
+
currentCursor(): OperatorCursor;
|
|
19
|
+
/**
|
|
20
|
+
* Persist and prune one complete change batch atomically.
|
|
21
|
+
* @param events - Validated state changes in publication order.
|
|
22
|
+
* @returns Durable envelopes safe to publish after this call succeeds.
|
|
23
|
+
*/
|
|
24
|
+
append(events: readonly OperatorStateEvent[]): OperatorEventEnvelope[];
|
|
25
|
+
/**
|
|
26
|
+
* Read an exact retained suffix after one client cursor.
|
|
27
|
+
* @param cursor - Last authoritative event already incorporated by the client.
|
|
28
|
+
* @returns Ordered events or an explicit replacement condition.
|
|
29
|
+
*/
|
|
30
|
+
replay(cursor: OperatorCursor): OperatorReplayResult;
|
|
31
|
+
/** Release resources owned by the store. */
|
|
32
|
+
close(): void;
|
|
33
|
+
}
|
|
34
|
+
/** SQLite-backed bounded replay storage owned by one broker epoch. */
|
|
35
|
+
export declare class SqliteOperatorEventStore implements OperatorEventStorePort {
|
|
36
|
+
#private;
|
|
37
|
+
/**
|
|
38
|
+
* Open replay storage adjacent to the broker's durable state.
|
|
39
|
+
* @param databasePath - Broker SQLite path containing the operator stream tables.
|
|
40
|
+
* @param epoch - Current broker generation that owns new event sequence values.
|
|
41
|
+
* @param replayLimit - Maximum number of event envelopes retained globally.
|
|
42
|
+
* @param openDatabase - Opens the connection this store owns and closes.
|
|
43
|
+
*/
|
|
44
|
+
constructor(databasePath: string, epoch: string, replayLimit?: number, openDatabase?: (path: string) => BrokerDatabase);
|
|
45
|
+
/** @inheritdoc */
|
|
46
|
+
currentCursor(): OperatorCursor;
|
|
47
|
+
/** @inheritdoc */
|
|
48
|
+
append(events: readonly OperatorStateEvent[]): OperatorEventEnvelope[];
|
|
49
|
+
/** @inheritdoc */
|
|
50
|
+
replay(cursor: OperatorCursor): OperatorReplayResult;
|
|
51
|
+
/** @inheritdoc */
|
|
52
|
+
close(): void;
|
|
53
|
+
}
|