ompclaw 0.3.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.
@@ -0,0 +1,410 @@
1
+ import {
2
+ type ConversationAddress,
3
+ type DeliveryContext,
4
+ type InboundEnvelope,
5
+ type InboundMessage,
6
+ type OutboundContent,
7
+ type OutboundReceipt,
8
+ type Reaction,
9
+ type ResolveTransportIdentity,
10
+ type TransportAdapter,
11
+ type TransportCapability,
12
+ type TransportIdentity,
13
+ type UiRequest,
14
+ type UiResponseFor,
15
+ UnsupportedTransportCapabilityError,
16
+ } from "./gateway-types";
17
+
18
+ export type InboundHandler = (message: InboundMessage, signal?: AbortSignal) => void | Promise<void>;
19
+
20
+ export interface GatewayCoreOptions {
21
+ readonly identityResolver: ResolveTransportIdentity;
22
+ readonly onInbound: InboundHandler;
23
+ }
24
+
25
+ export class DuplicateTransportAdapterError extends Error {
26
+ readonly name = "DuplicateTransportAdapterError";
27
+
28
+ constructor(readonly transport: string) {
29
+ super(`A transport adapter is already registered for ${transport}`);
30
+ }
31
+ }
32
+
33
+ export class UnknownTransportAdapterError extends Error {
34
+ readonly name = "UnknownTransportAdapterError";
35
+
36
+ constructor(readonly transport: string) {
37
+ super(`No transport adapter is registered for ${transport}`);
38
+ }
39
+ }
40
+
41
+ export class UnknownTransportIdentityError extends Error {
42
+ readonly name = "UnknownTransportIdentityError";
43
+
44
+ constructor(readonly identity: TransportIdentity) {
45
+ super(`No principal is resolved for ${identity.transport}/${identity.account}/${identity.subject}`);
46
+ }
47
+ }
48
+ export class InvalidInboundEnvelopeIdError extends Error {
49
+ readonly name = "InvalidInboundEnvelopeIdError";
50
+
51
+ constructor(readonly id: unknown) {
52
+ super("Inbound envelope id must be a non-empty string");
53
+ }
54
+ }
55
+
56
+ export class InvalidInboundEnvelopeSentAtError extends Error {
57
+ readonly name = "InvalidInboundEnvelopeSentAtError";
58
+
59
+ constructor(readonly sentAt: unknown) {
60
+ super("Inbound envelope sentAt must be a safe nonnegative integer");
61
+ }
62
+ }
63
+
64
+ export class InboundIdentityAddressMismatchError extends Error {
65
+ readonly name = "InboundIdentityAddressMismatchError";
66
+
67
+ constructor(
68
+ readonly identity: TransportIdentity,
69
+ readonly address: ConversationAddress,
70
+ ) {
71
+ super(
72
+ `Inbound identity ${identity.transport}/${identity.account} does not match address ${address.transport}/${address.account}`,
73
+ );
74
+ }
75
+ }
76
+
77
+ export class InboundReplyReceiptTransportMismatchError extends Error {
78
+ readonly name = "InboundReplyReceiptTransportMismatchError";
79
+
80
+ constructor(
81
+ readonly expectedTransport: string,
82
+ readonly actualTransport: string,
83
+ ) {
84
+ super(`Inbound reply receipt transport ${actualTransport} does not match address transport ${expectedTransport}`);
85
+ }
86
+ }
87
+
88
+
89
+ export class InboundTransportMismatchError extends Error {
90
+ readonly name = "InboundTransportMismatchError";
91
+
92
+ constructor(
93
+ readonly adapterId: string,
94
+ readonly identityTransport: string,
95
+ readonly addressTransport: string,
96
+ ) {
97
+ super(
98
+ `Adapter ${adapterId} cannot deliver an envelope for identity transport ${identityTransport} and address transport ${addressTransport}`,
99
+ );
100
+ }
101
+ }
102
+
103
+ export class ReceiptTransportMismatchError extends Error {
104
+ readonly name = "ReceiptTransportMismatchError";
105
+
106
+ constructor(
107
+ readonly expectedTransport: string,
108
+ readonly actualTransport: string,
109
+ ) {
110
+ super(`Receipt transport ${actualTransport} does not match address transport ${expectedTransport}`);
111
+ }
112
+ }
113
+ export class CrossOriginDeliveryError extends Error {
114
+ readonly name = "CrossOriginDeliveryError";
115
+
116
+ constructor(
117
+ readonly origin: ConversationAddress,
118
+ readonly address: ConversationAddress,
119
+ ) {
120
+ super(
121
+ `Outbound address ${address.transport}/${address.account}/${address.channel}/${address.thread ?? ""} does not match delivery origin ${origin.transport}/${origin.account}/${origin.channel}/${origin.thread ?? ""}`,
122
+ );
123
+ }
124
+ }
125
+
126
+
127
+ export class OutboundContentTooLongError extends Error {
128
+ readonly name = "OutboundContentTooLongError";
129
+
130
+ constructor(
131
+ readonly transport: string,
132
+ readonly length: number,
133
+ readonly maxLength: number,
134
+ ) {
135
+ super(`Outbound content for ${transport} is ${length} characters; maximum is ${maxLength}`);
136
+ }
137
+ }
138
+
139
+ export class UnsupportedUiRequestError extends Error {
140
+ readonly name = "UnsupportedUiRequestError";
141
+
142
+ constructor(
143
+ readonly transport: string,
144
+ readonly requestType: UiRequest["type"],
145
+ ) {
146
+ super(`Transport ${transport} cannot present ${requestType} UI`);
147
+ }
148
+ }
149
+
150
+ export class GatewayLifecycleError extends Error {
151
+ readonly name = "GatewayLifecycleError";
152
+
153
+ constructor(readonly operation: "register" | "start" | "stop", readonly state: GatewayState) {
154
+ super(`Cannot ${operation} gateway while it is ${state}`);
155
+ }
156
+ }
157
+
158
+ type GatewayState = "idle" | "starting" | "started" | "stopping";
159
+
160
+ /**
161
+ * In-process transport coordinator. Addresses contain the exact registered
162
+ * transport ID; no aliases or client-provided principals are accepted here.
163
+ */
164
+ export class GatewayCore {
165
+ readonly #identityResolver: ResolveTransportIdentity;
166
+ readonly #onInbound: InboundHandler;
167
+ readonly #adapters = new Map<string, TransportAdapter>();
168
+ #startedAdapters: TransportAdapter[] = [];
169
+ #state: GatewayState = "idle";
170
+
171
+ constructor(options: GatewayCoreOptions) {
172
+ this.#identityResolver = options.identityResolver;
173
+ this.#onInbound = options.onInbound;
174
+ }
175
+
176
+ register(adapter: TransportAdapter): void {
177
+ if (this.#state !== "idle") throw new GatewayLifecycleError("register", this.#state);
178
+ if (this.#adapters.has(adapter.id)) throw new DuplicateTransportAdapterError(adapter.id);
179
+ this.#adapters.set(adapter.id, adapter);
180
+ }
181
+
182
+ async start(signal?: AbortSignal): Promise<void> {
183
+ if (this.#state === "started") return;
184
+ if (this.#state !== "idle") throw new GatewayLifecycleError("start", this.#state);
185
+
186
+ this.#state = "starting";
187
+ const started: TransportAdapter[] = [];
188
+ try {
189
+ for (const adapter of this.#adapters.values()) {
190
+ await adapter.start({
191
+ resolveIdentity: this.#identityResolver,
192
+ receive: (envelope, inboundSignal) => this.#receiveFrom(adapter.id, envelope, inboundSignal ?? signal),
193
+ signal,
194
+ });
195
+ started.push(adapter);
196
+ }
197
+ this.#startedAdapters = started;
198
+ this.#state = "started";
199
+ } catch (error) {
200
+ await this.#stopInReverse(started);
201
+ this.#startedAdapters = [];
202
+ this.#state = "idle";
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ async stop(): Promise<void> {
208
+ if (this.#state === "idle") return;
209
+ if (this.#state !== "started") throw new GatewayLifecycleError("stop", this.#state);
210
+
211
+ this.#state = "stopping";
212
+ const error = await this.#stopInReverse(this.#startedAdapters);
213
+ this.#startedAdapters = [];
214
+ this.#state = "idle";
215
+ if (error !== undefined) throw error;
216
+ }
217
+
218
+ async receive(envelope: InboundEnvelope, signal?: AbortSignal): Promise<void> {
219
+ this.#assertInboundEnvelope(envelope);
220
+ await this.#receiveValidated(envelope, signal);
221
+ }
222
+
223
+ async send(
224
+ address: ConversationAddress,
225
+ content: OutboundContent,
226
+ context: DeliveryContext,
227
+ signal?: AbortSignal,
228
+ ): Promise<OutboundReceipt> {
229
+ this.#assertDeliveryOrigin(context, address);
230
+ const adapter = this.#adapterFor(address);
231
+ this.#assertAddressSupported(adapter, address);
232
+ this.#assertContentSupported(adapter, address, content);
233
+ return adapter.send(address, content, context, signal);
234
+ }
235
+
236
+ async update(
237
+ address: ConversationAddress,
238
+ receipt: OutboundReceipt,
239
+ content: OutboundContent,
240
+ context: DeliveryContext,
241
+ signal?: AbortSignal,
242
+ ): Promise<OutboundReceipt> {
243
+ this.#assertDeliveryOrigin(context, address);
244
+ const adapter = this.#adapterFor(address);
245
+ this.#assertReceiptTransport(address, receipt);
246
+ this.#assertAddressSupported(adapter, address);
247
+ this.#assertContentSupported(adapter, address, content);
248
+ this.#requireCapability(adapter, "streamingUpdates", adapter.update !== undefined);
249
+ return adapter.update!(address, receipt, content, context, signal);
250
+ }
251
+
252
+ async react(
253
+ address: ConversationAddress,
254
+ receipt: OutboundReceipt,
255
+ reaction: Reaction,
256
+ context: DeliveryContext,
257
+ signal?: AbortSignal,
258
+ ): Promise<void> {
259
+ this.#assertDeliveryOrigin(context, address);
260
+ const adapter = this.#adapterFor(address);
261
+ this.#assertReceiptTransport(address, receipt);
262
+ this.#assertAddressSupported(adapter, address);
263
+ this.#requireCapability(adapter, "reactions", adapter.react !== undefined);
264
+ await adapter.react!(address, receipt, reaction, context, signal);
265
+ }
266
+
267
+ async presentUi<Request extends UiRequest>(
268
+ address: ConversationAddress,
269
+ request: Request,
270
+ context: DeliveryContext,
271
+ signal?: AbortSignal,
272
+ ): Promise<UiResponseFor<Request>> {
273
+ this.#assertDeliveryOrigin(context, address);
274
+ const adapter = this.#adapterFor(address);
275
+ this.#assertAddressSupported(adapter, address);
276
+ this.#assertUiCapabilities(adapter, request);
277
+ if (adapter.presentUi === undefined) throw new UnsupportedUiRequestError(adapter.id, request.type);
278
+ return adapter.presentUi(address, request, context, signal);
279
+ }
280
+
281
+ #adapterFor(address: ConversationAddress): TransportAdapter {
282
+ const adapter = this.#adapters.get(address.transport);
283
+ if (adapter === undefined) throw new UnknownTransportAdapterError(address.transport);
284
+ return adapter;
285
+ }
286
+
287
+ async #receiveFrom(adapterId: string, envelope: InboundEnvelope, signal?: AbortSignal): Promise<void> {
288
+ this.#assertInboundEnvelope(envelope);
289
+ if (envelope.identity.transport !== adapterId || envelope.address.transport !== adapterId) {
290
+ throw new InboundTransportMismatchError(adapterId, envelope.identity.transport, envelope.address.transport);
291
+ }
292
+ await this.#receiveValidated(envelope, signal);
293
+ }
294
+
295
+ async #receiveValidated(envelope: InboundEnvelope, signal?: AbortSignal): Promise<void> {
296
+ const principal = await this.#identityResolver(envelope.identity, signal);
297
+ if (principal === undefined) throw new UnknownTransportIdentityError(envelope.identity);
298
+
299
+ const { id, sentAt, identity, address, content, replyTo, edited } = envelope;
300
+ await this.#onInbound(
301
+ {
302
+ id,
303
+ sentAt,
304
+ identity,
305
+ address,
306
+ content,
307
+ ...(replyTo === undefined ? {} : { replyTo }),
308
+ ...(edited === undefined ? {} : { edited }),
309
+ principal,
310
+ },
311
+ signal,
312
+ );
313
+ }
314
+
315
+ #assertReceiptTransport(address: ConversationAddress, receipt: OutboundReceipt): void {
316
+ if (receipt.transport !== address.transport) {
317
+ throw new ReceiptTransportMismatchError(address.transport, receipt.transport);
318
+ }
319
+ }
320
+ #assertInboundEnvelope(envelope: InboundEnvelope): void {
321
+ if (typeof envelope.id !== "string" || envelope.id.length === 0) {
322
+ throw new InvalidInboundEnvelopeIdError(envelope.id);
323
+ }
324
+ if (
325
+ typeof envelope.sentAt !== "number" ||
326
+ !Number.isSafeInteger(envelope.sentAt) ||
327
+ envelope.sentAt < 0
328
+ ) {
329
+ throw new InvalidInboundEnvelopeSentAtError(envelope.sentAt);
330
+ }
331
+ if (
332
+ envelope.identity.transport !== envelope.address.transport ||
333
+ envelope.identity.account !== envelope.address.account
334
+ ) {
335
+ throw new InboundIdentityAddressMismatchError(envelope.identity, envelope.address);
336
+ }
337
+ if (envelope.replyTo !== undefined && envelope.replyTo.transport !== envelope.address.transport) {
338
+ throw new InboundReplyReceiptTransportMismatchError(envelope.address.transport, envelope.replyTo.transport);
339
+ }
340
+ }
341
+
342
+ #assertDeliveryOrigin(context: DeliveryContext, address: ConversationAddress): void {
343
+ if (
344
+ context.origin.transport !== address.transport ||
345
+ context.origin.account !== address.account ||
346
+ context.origin.channel !== address.channel ||
347
+ context.origin.thread !== address.thread
348
+ ) {
349
+ throw new CrossOriginDeliveryError(context.origin, address);
350
+ }
351
+ }
352
+
353
+
354
+ #assertAddressSupported(adapter: TransportAdapter, address: ConversationAddress): void {
355
+ if (address.thread !== undefined) this.#requireCapability(adapter, "threads");
356
+ }
357
+
358
+ #assertContentSupported(adapter: TransportAdapter, address: ConversationAddress, content: OutboundContent): void {
359
+ if (content.replyTo !== undefined) this.#assertReceiptTransport(address, content.replyTo);
360
+ if (content.attachments !== undefined && content.attachments.length > 0) {
361
+ this.#requireCapability(adapter, "attachments");
362
+ }
363
+ if (content.text !== undefined && content.text.length > adapter.capabilities.maxMessageLength) {
364
+ throw new OutboundContentTooLongError(adapter.id, content.text.length, adapter.capabilities.maxMessageLength);
365
+ }
366
+ }
367
+
368
+ #assertUiCapabilities(adapter: TransportAdapter, request: UiRequest): void {
369
+ switch (request.type) {
370
+ case "confirm":
371
+ case "open_url":
372
+ this.#requireCapability(adapter, "buttons");
373
+ return;
374
+ case "select":
375
+ this.#requireCapability(adapter, "buttons");
376
+ if (request.multiSelect === true) this.#requireCapability(adapter, "multiSelect");
377
+ return;
378
+ case "input":
379
+ case "editor":
380
+ this.#requireCapability(adapter, "textInput");
381
+ return;
382
+ case "notify":
383
+ case "status":
384
+ case "widget":
385
+ case "title":
386
+ case "editor_text":
387
+ return;
388
+ }
389
+ }
390
+
391
+ #requireCapability(adapter: TransportAdapter, capability: TransportCapability, implemented = true): void {
392
+ if (!implemented || !adapter.capabilities[capability]) {
393
+ throw new UnsupportedTransportCapabilityError(adapter.id, capability);
394
+ }
395
+ }
396
+
397
+ async #stopInReverse(adapters: readonly TransportAdapter[]): Promise<unknown> {
398
+ let firstError: unknown;
399
+ for (let index = adapters.length - 1; index >= 0; index -= 1) {
400
+ try {
401
+ await adapters[index]!.stop();
402
+ } catch (error) {
403
+ firstError ??= error;
404
+ }
405
+ }
406
+ return firstError;
407
+ }
408
+ }
409
+
410
+ export { UnsupportedTransportCapabilityError } from "./gateway-types";