@inixiative/signet 0.4.3 → 0.5.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.
@@ -1,8 +1,69 @@
1
1
  import { z } from 'zod';
2
2
  import { installationInquiries, installationSignets, kingdomUrl, signetProof } from './client';
3
- import { installationSnapshotSchema, installationSocketAction } from './contracts';
3
+ import {
4
+ type InstallationSnapshot,
5
+ installationSocketAction,
6
+ parseInstallationSnapshot,
7
+ } from './contracts';
8
+ import { type ThreadStreamHandlers, type ThreadStreamKey, ThreadStreams } from './threadStreams';
9
+ import {
10
+ type ThreadAppendFrame,
11
+ type ThreadEntry,
12
+ type ThreadOperation,
13
+ type ThreadOutcome,
14
+ type ThreadRefusal,
15
+ type ThreadRequestFrame,
16
+ type ThreadSubscribeFrame,
17
+ type ThreadsLens,
18
+ threadAppendFrameSchema,
19
+ threadLeaseFrameSchema,
20
+ threadRefused,
21
+ threadRequestFrameSchema,
22
+ threadResponseFrameSchema,
23
+ threadSubscribeFrameSchema,
24
+ threadSubscriptionLeaseMs,
25
+ threadUnsubscribeFrameSchema,
26
+ } from './threads';
4
27
 
5
- export type InstallationSnapshot = z.infer<typeof installationSnapshotSchema>;
28
+ export type ThreadRequestOf<O extends ThreadOperation> = Extract<
29
+ ThreadRequestFrame,
30
+ { operation: O }
31
+ >;
32
+
33
+ /** A member's live view this host is pushing, from the moment its snapshot was taken. */
34
+ export type ThreadSubscription = {
35
+ subscriptionId: string;
36
+ signetId: string;
37
+ threadId: string;
38
+ acceptedRevision: number;
39
+ member: ThreadSubscribeFrame['member'];
40
+ lens: ThreadsLens;
41
+ };
42
+ /** `overflow`: too many entries arrived while the snapshot was taken; the subscribe is refused and retried. */
43
+ export type ThreadSubscriptionEnd = 'unsubscribed' | 'lease' | 'disconnected' | 'overflow';
44
+
45
+ /**
46
+ * A host Foundry's side of agent conversations: one handler per operation, typed by what Kingdom
47
+ * relays and what it reads back. Each answers within Kingdom's 15 s wait, with `threadResult(...)`
48
+ * or `threadRefused(code)`. A handler that throws, or answers with something the contract doesn't
49
+ * allow, is answered `failed`.
50
+ */
51
+ export type ThreadHost = {
52
+ [O in ThreadOperation]: (
53
+ frame: ThreadRequestOf<O>,
54
+ ) => ThreadOutcome<O> | Promise<ThreadOutcome<O>>;
55
+ } & {
56
+ /**
57
+ * The member's view and the `journalSeq` it was taken at. The subscription is registered before
58
+ * this runs, and appends pushed meanwhile are held and sent after the answer, minus those at or
59
+ * below `journalSeq`: snapshot and subscription start at the same point (review V3).
60
+ */
61
+ 'thread.subscribe': (
62
+ frame: ThreadSubscribeFrame,
63
+ ) => ThreadOutcome<'thread.subscribe'> | Promise<ThreadOutcome<'thread.subscribe'>>;
64
+ /** A subscription ended: Kingdom unsubscribed, its lease lapsed, or this socket dropped. */
65
+ ended?: (subscription: ThreadSubscription, reason: ThreadSubscriptionEnd) => void;
66
+ };
6
67
 
7
68
  export type InstallationSocketOptions = {
8
69
  url: string;
@@ -15,27 +76,60 @@ export type InstallationSocketOptions = {
15
76
  viewerUrl?: string | null;
16
77
  /** Reported on every ping so Kingdom can show how busy this installation is. */
17
78
  sessionCount?: () => number;
79
+ /** Serve conversations on this installation's Foundry integrations (the host side). */
80
+ threads?: ThreadHost;
81
+ /** Hold live views of threads on other Foundries through Signets this installation holds. */
82
+ threadStreams?: ThreadStreamHandlers;
18
83
  pingMs?: number;
19
84
  pollMs?: number;
20
85
  retryBaseMs?: number;
21
86
  retryMaxMs?: number;
22
87
  /** A Kingdom that never answers the proof (one without Installation sockets) is treated as down. */
23
88
  authTimeoutMs?: number;
89
+ /** How long a subscription lives without Kingdom re-asserting it. */
90
+ leaseMs?: number;
24
91
  WebSocket?: typeof WebSocket;
25
92
  };
26
93
 
27
94
  const frameSchema = z.object({ type: z.string() }).loose();
28
95
 
96
+ type HeldSubscription = ThreadSubscription & {
97
+ leaseUntil: number;
98
+ /** The last `journalSeq` covered (the snapshot's, then each append's); null while it is taken. */
99
+ sentSeq: number | null;
100
+ /** The last append's dense `seq` (0 = the snapshot). */
101
+ seq: number;
102
+ held: ThreadEntry[];
103
+ };
104
+ /** Entries held while one snapshot is taken; past this the subscription ends (Kingdom resyncs). */
105
+ const maxHeldEntries = 1_000;
106
+ /** WebSocket.OPEN, read from the spec rather than whichever WebSocket class was passed. */
107
+ const OPEN = 1;
108
+
109
+ /** An unknown selector form (or a `within`, which no v1 form fills) is refused as `unknown_selector`. */
110
+ const unknownSelector = (frame: Record<string, unknown>) => {
111
+ const threads = z
112
+ .object({ threads: z.object({ form: z.string(), within: z.unknown().optional() }).loose() })
113
+ .loose()
114
+ .safeParse(frame.lens);
115
+ return (
116
+ threads.success &&
117
+ (threads.data.threads.form !== 'ids' || threads.data.threads.within !== undefined)
118
+ );
119
+ };
120
+
29
121
  /**
30
122
  * Kingdom's live line to an Installation. It proves the installation's key, then receives a
31
123
  * snapshot of its inquiries and Signets on connect and again whenever one changes. While the
32
- * socket is down it polls the same reads, so nothing waits on the socket coming back.
124
+ * socket is down it polls the same reads, so nothing waits on the socket coming back. A Foundry
125
+ * also answers its members' calls over it (`threads`) and holds its own memberships (`threadStreams`).
33
126
  */
34
127
  export class InstallationSocket {
35
128
  private socket: WebSocket | null = null;
36
129
  private stopped = false;
37
130
  private attempts = 0;
38
131
  private pingTimer: ReturnType<typeof setInterval> | null = null;
132
+ private leaseTimer: ReturnType<typeof setInterval> | null = null;
39
133
  private pollTimer: ReturnType<typeof setTimeout> | null = null;
40
134
  private retryTimer: ReturnType<typeof setTimeout> | null = null;
41
135
  private authTimer: ReturnType<typeof setTimeout> | null = null;
@@ -43,9 +137,16 @@ export class InstallationSocket {
43
137
  private polling = false;
44
138
  private pollGeneration = 0;
45
139
  private viewerUrl: string | null;
140
+ private readonly subscriptions = new Map<string, HeldSubscription>();
141
+ private readonly streams: ThreadStreams | null;
46
142
 
47
143
  constructor(private readonly options: InstallationSocketOptions) {
48
144
  this.viewerUrl = options.viewerUrl ?? null;
145
+ this.streams = options.threadStreams
146
+ ? new ThreadStreams((frame) => this.send(frame), options.threadStreams, {
147
+ retryBaseMs: options.retryBaseMs,
148
+ })
149
+ : null;
49
150
  }
50
151
 
51
152
  get connected() {
@@ -75,6 +176,63 @@ export class InstallationSocket {
75
176
  this.socket?.close(1000, 'closed');
76
177
  this.socket = null;
77
178
  this.authenticated = false;
179
+ this.endSubscriptions('disconnected');
180
+ this.streams?.down();
181
+ }
182
+
183
+ /** The members' views this host is pushing now. */
184
+ threadSubscriptions(): ThreadSubscription[] {
185
+ return [...this.subscriptions.values()].map(
186
+ ({ leaseUntil: _lease, sentSeq: _sent, seq: _seq, held: _held, ...subscription }) =>
187
+ subscription,
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Push one journal entry to one subscription, already filtered to that member's view. Entries at
193
+ * or below what was covered are skipped; while the snapshot is being taken they are held. An
194
+ * entry that breaks the contract still spends its `seq`, so Kingdom sees the gap and the member
195
+ * resyncs rather than silently missing it. False when the subscription is gone.
196
+ */
197
+ appendThread(subscriptionId: string, entry: ThreadEntry) {
198
+ const subscription = this.subscriptions.get(subscriptionId);
199
+ if (!subscription) return false;
200
+ if (subscription.sentSeq === null) {
201
+ if (subscription.held.length >= maxHeldEntries) {
202
+ this.endSubscription(subscriptionId, 'overflow');
203
+ return false;
204
+ }
205
+ subscription.held.push(entry);
206
+ return true;
207
+ }
208
+ if (entry.journalSeq <= subscription.sentSeq) return true;
209
+ subscription.sentSeq = entry.journalSeq;
210
+ subscription.seq += 1;
211
+ const frame = threadAppendFrameSchema.safeParse({
212
+ action: 'thread.append',
213
+ subscriptionId,
214
+ signetId: subscription.signetId,
215
+ threadId: subscription.threadId,
216
+ seq: subscription.seq,
217
+ journalSeq: entry.journalSeq,
218
+ entry,
219
+ } satisfies ThreadAppendFrame);
220
+ if (!frame.success) {
221
+ this.options.onError?.(frame.error);
222
+ return true;
223
+ }
224
+ this.send(frame.data);
225
+ return true;
226
+ }
227
+
228
+ /** Open a live view of a thread through a Signet this installation holds (needs `threadStreams`). */
229
+ openThread(stream: ThreadStreamKey) {
230
+ if (!this.streams) throw Error('Pass threadStreams to hold thread views');
231
+ this.streams.open(stream);
232
+ }
233
+
234
+ leaveThread(stream: ThreadStreamKey) {
235
+ this.streams?.leave(stream);
78
236
  }
79
237
 
80
238
  private connect() {
@@ -89,13 +247,16 @@ export class InstallationSocket {
89
247
  }
90
248
 
91
249
  private async receive(socket: WebSocket, raw: string) {
92
- let parsed: ReturnType<typeof frameSchema.safeParse>;
250
+ let json: unknown;
93
251
  try {
94
- parsed = frameSchema.safeParse(JSON.parse(raw));
252
+ json = JSON.parse(raw);
95
253
  } catch {
96
254
  return;
97
255
  }
98
- if (!parsed.success || socket !== this.socket) return;
256
+ if (socket !== this.socket || typeof json !== 'object' || json === null) return;
257
+ if (this.streams?.receive(json as Record<string, unknown>)) return;
258
+ const parsed = frameSchema.safeParse(json);
259
+ if (!parsed.success) return;
99
260
  const frame = parsed.data;
100
261
  try {
101
262
  switch (frame.type) {
@@ -120,10 +281,15 @@ export class InstallationSocket {
120
281
  () => this.send({ action: 'ping', sessionCount: this.options.sessionCount?.() }),
121
282
  this.options.pingMs ?? 20_000,
122
283
  );
284
+ this.leaseTimer ??= setInterval(
285
+ () => this.expireLeases(),
286
+ Math.min(this.leaseMs() / 3, 5_000),
287
+ );
123
288
  if (this.viewerUrl) this.send({ action: 'advertise', viewerUrl: this.viewerUrl });
289
+ this.streams?.up();
124
290
  return;
125
291
  case 'installation.snapshot':
126
- await this.options.onSnapshot(installationSnapshotSchema.parse(frame));
292
+ await this.options.onSnapshot(parseInstallationSnapshot(frame));
127
293
  return;
128
294
  case 'installationRejected':
129
295
  socket.close();
@@ -138,6 +304,13 @@ export class InstallationSocket {
138
304
  case 'advertiseRejected':
139
305
  this.options.onError?.(Error(`Kingdom refused viewer URL ${this.viewerUrl}`));
140
306
  return;
307
+ case 'thread.request':
308
+ case 'thread.subscribe':
309
+ case 'thread.unsubscribe':
310
+ case 'thread.lease':
311
+ // Only an authenticated Installation socket carries conversation frames.
312
+ if (this.authenticated) await this.threadFrame(socket, frame);
313
+ return;
141
314
  }
142
315
  } catch (error) {
143
316
  this.options.onError?.(error);
@@ -145,13 +318,199 @@ export class InstallationSocket {
145
318
  }
146
319
  }
147
320
 
321
+ private async threadFrame(socket: WebSocket, frame: Record<string, unknown>) {
322
+ switch (frame.type) {
323
+ case 'thread.request':
324
+ await this.threadRequest(socket, frame);
325
+ return;
326
+ case 'thread.subscribe':
327
+ await this.threadSubscribe(socket, frame);
328
+ return;
329
+ case 'thread.unsubscribe': {
330
+ const { subscriptionId } = threadUnsubscribeFrameSchema.parse(frame);
331
+ this.endSubscription(subscriptionId, 'unsubscribed');
332
+ return;
333
+ }
334
+ case 'thread.lease': {
335
+ const { subscriptionIds } = threadLeaseFrameSchema.parse(frame);
336
+ const leaseUntil = Date.now() + this.leaseMs();
337
+ for (const id of subscriptionIds) {
338
+ const subscription = this.subscriptions.get(id);
339
+ if (subscription) subscription.leaseUntil = leaseUntil;
340
+ }
341
+ return;
342
+ }
343
+ }
344
+ }
345
+
346
+ private async threadRequest(socket: WebSocket, raw: Record<string, unknown>) {
347
+ const requestId = z.uuid().safeParse(raw.requestId);
348
+ if (!requestId.success) return this.options.onError?.(requestId.error);
349
+ const frame = threadRequestFrameSchema.safeParse(raw);
350
+ if (!frame.success) {
351
+ this.options.onError?.(frame.error);
352
+ return this.refuse(
353
+ socket,
354
+ requestId.data,
355
+ unknownSelector(raw) ? 'unknown_selector' : 'invalid_request',
356
+ );
357
+ }
358
+ const host = this.options.threads;
359
+ if (!host) return this.refuse(socket, requestId.data, 'failed', 'This host serves no threads');
360
+ try {
361
+ const request = frame.data;
362
+ switch (request.operation) {
363
+ case 'thread.read':
364
+ return this.respond(
365
+ socket,
366
+ request.requestId,
367
+ request.operation,
368
+ await host[request.operation](request),
369
+ );
370
+ case 'thread.prompt':
371
+ return this.respond(
372
+ socket,
373
+ request.requestId,
374
+ request.operation,
375
+ await host[request.operation](request),
376
+ );
377
+ case 'thread.floor':
378
+ return this.respond(
379
+ socket,
380
+ request.requestId,
381
+ request.operation,
382
+ await host[request.operation](request),
383
+ );
384
+ case 'prompt.resolve':
385
+ return this.respond(
386
+ socket,
387
+ request.requestId,
388
+ request.operation,
389
+ await host[request.operation](request),
390
+ );
391
+ }
392
+ } catch (error) {
393
+ this.options.onError?.(error);
394
+ return this.refuse(socket, requestId.data, 'failed');
395
+ }
396
+ }
397
+
398
+ private async threadSubscribe(socket: WebSocket, raw: Record<string, unknown>) {
399
+ const requestId = z.uuid().safeParse(raw.requestId);
400
+ if (!requestId.success) return this.options.onError?.(requestId.error);
401
+ const parsed = threadSubscribeFrameSchema.safeParse(raw);
402
+ if (!parsed.success) {
403
+ this.options.onError?.(parsed.error);
404
+ return this.refuse(
405
+ socket,
406
+ requestId.data,
407
+ unknownSelector(raw) ? 'unknown_selector' : 'invalid_request',
408
+ );
409
+ }
410
+ const host = this.options.threads;
411
+ if (!host) return this.refuse(socket, requestId.data, 'failed', 'This host serves no threads');
412
+ const frame = parsed.data;
413
+ const subscription: HeldSubscription = {
414
+ subscriptionId: frame.requestId,
415
+ signetId: frame.signetId,
416
+ threadId: frame.threadId,
417
+ acceptedRevision: frame.acceptedRevision,
418
+ member: frame.member,
419
+ lens: frame.lens,
420
+ leaseUntil: Date.now() + this.leaseMs(),
421
+ sentSeq: null,
422
+ seq: 0,
423
+ held: [],
424
+ };
425
+ // Registered before the snapshot is taken, so nothing written meanwhile is lost (review V3).
426
+ this.subscriptions.set(subscription.subscriptionId, subscription);
427
+ let outcome: ThreadOutcome<'thread.subscribe'>;
428
+ try {
429
+ outcome = await host['thread.subscribe'](frame);
430
+ } catch (error) {
431
+ this.options.onError?.(error);
432
+ outcome = threadRefused('failed');
433
+ }
434
+ // Ended while the view was taken (overflow, unsubscribe, lease): a view no subscription will
435
+ // follow would freeze the member, so refuse and let Kingdom's open fail and be retried.
436
+ if (this.subscriptions.get(subscription.subscriptionId) !== subscription) {
437
+ this.refuse(
438
+ socket,
439
+ frame.requestId,
440
+ 'failed',
441
+ 'The subscription ended while its view was taken',
442
+ );
443
+ return;
444
+ }
445
+ const answered = this.respond(socket, frame.requestId, 'thread.subscribe', outcome);
446
+ if (!answered || !outcome.ok) {
447
+ this.subscriptions.delete(subscription.subscriptionId);
448
+ return;
449
+ }
450
+ subscription.sentSeq = outcome.result.journalSeq;
451
+ const held = subscription.held.sort((a, b) => a.journalSeq - b.journalSeq);
452
+ subscription.held = [];
453
+ for (const entry of held) this.appendThread(subscription.subscriptionId, entry);
454
+ }
455
+
456
+ /** Answers a request; false (after answering `failed`) when the handler's outcome breaks the contract. */
457
+ private respond<O extends ThreadOperation | 'thread.subscribe'>(
458
+ socket: WebSocket,
459
+ requestId: string,
460
+ operation: O,
461
+ outcome: ThreadOutcome<O>,
462
+ ) {
463
+ const frame = threadResponseFrameSchema(operation).safeParse({
464
+ action: 'thread.response',
465
+ requestId,
466
+ outcome,
467
+ });
468
+ if (!frame.success) {
469
+ this.options.onError?.(frame.error);
470
+ this.refuse(socket, requestId, 'failed');
471
+ return false;
472
+ }
473
+ this.sendOn(socket, frame.data);
474
+ return true;
475
+ }
476
+
477
+ private refuse(socket: WebSocket, requestId: string, refusal: ThreadRefusal, message?: string) {
478
+ this.sendOn(socket, {
479
+ action: 'thread.response',
480
+ requestId,
481
+ outcome: threadRefused(refusal, message),
482
+ });
483
+ }
484
+
485
+ private leaseMs() {
486
+ return this.options.leaseMs ?? threadSubscriptionLeaseMs;
487
+ }
488
+
489
+ private expireLeases() {
490
+ const now = Date.now();
491
+ for (const subscription of [...this.subscriptions.values()])
492
+ if (subscription.leaseUntil <= now)
493
+ this.endSubscription(subscription.subscriptionId, 'lease');
494
+ }
495
+
496
+ private endSubscription(subscriptionId: string, reason: ThreadSubscriptionEnd) {
497
+ const subscription = this.subscriptions.get(subscriptionId);
498
+ if (!subscription) return;
499
+ this.subscriptions.delete(subscriptionId);
500
+ const { leaseUntil: _lease, sentSeq: _sent, seq: _seq, held: _held, ...ended } = subscription;
501
+ this.options.threads?.ended?.(ended, reason);
502
+ }
503
+
504
+ private endSubscriptions(reason: ThreadSubscriptionEnd) {
505
+ for (const id of [...this.subscriptions.keys()]) this.endSubscription(id, reason);
506
+ }
507
+
148
508
  private send(frame: Record<string, unknown>) {
149
509
  if (this.socket) this.sendOn(this.socket, frame);
150
510
  }
151
511
 
152
512
  private sendOn(socket: WebSocket, frame: Record<string, unknown>) {
153
- if (socket === this.socket && socket.readyState === WebSocket.OPEN)
154
- socket.send(JSON.stringify(frame));
513
+ if (socket === this.socket && socket.readyState === OPEN) socket.send(JSON.stringify(frame));
155
514
  }
156
515
 
157
516
  private disconnected(socket: WebSocket) {
@@ -162,6 +521,11 @@ export class InstallationSocket {
162
521
  this.authTimer = null;
163
522
  if (this.pingTimer) clearInterval(this.pingTimer);
164
523
  this.pingTimer = null;
524
+ if (this.leaseTimer) clearInterval(this.leaseTimer);
525
+ this.leaseTimer = null;
526
+ // Kingdom re-subscribes members after a reconnect; nothing pushes into a dead line meanwhile.
527
+ this.endSubscriptions('disconnected');
528
+ this.streams?.down();
165
529
  if (this.stopped) return;
166
530
  if (!this.polling) void this.poll();
167
531
  const base = this.options.retryBaseMs ?? 1_000;
@@ -177,7 +541,12 @@ export class InstallationSocket {
177
541
  installationInquiries(this.options.url, this.options.keyFile),
178
542
  installationSignets(this.options.url, this.options.keyFile),
179
543
  ]);
180
- if (!this.authenticated) await this.options.onSnapshot({ ...inquiries, ...signets });
544
+ if (!this.authenticated)
545
+ await this.options.onSnapshot({
546
+ ...inquiries,
547
+ ...signets,
548
+ dropped: [...inquiries.dropped, ...signets.dropped],
549
+ });
181
550
  } catch (error) {
182
551
  this.options.onError?.(error);
183
552
  }
@@ -197,8 +566,10 @@ export class InstallationSocket {
197
566
  if (this.authTimer) clearTimeout(this.authTimer);
198
567
  this.authTimer = null;
199
568
  if (this.pingTimer) clearInterval(this.pingTimer);
569
+ if (this.leaseTimer) clearInterval(this.leaseTimer);
200
570
  if (this.retryTimer) clearTimeout(this.retryTimer);
201
571
  this.pingTimer = null;
572
+ this.leaseTimer = null;
202
573
  this.retryTimer = null;
203
574
  }
204
575
  }