@staix/agent-hub 0.31.0 → 0.32.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/src/rooms/link.ts CHANGED
@@ -4,6 +4,7 @@ import { assertRoomCryptoReady, encodeBase64url, sha256, RoomCryptoError } from
4
4
  import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, openFrame, roomMembers, sealFrame, verifyPublicRoster, RoomWireError, type FrameWire, type FrameOpenResult, type VerifiedPublicRoster } from "./wire.ts";
5
5
  import type { RoomRefusalReason, RoomState, RoomStore } from "./store.ts";
6
6
  import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, RoomStoreError, type RoomOutgoingInput } from "./store.ts";
7
+ import { ROOM_STOP_TIMEOUT_MS, roomRefreshPeriod } from "./liveness.ts";
7
8
  import { decodeRoomMessage, encodeRoomMessage, type RoomMessage } from "./application.ts";
8
9
 
9
10
  export type RoomLinkState = "offline" | "connecting" | "online";
@@ -13,9 +14,11 @@ export interface RoomLinkOptions {
13
14
  store: RoomStore;
14
15
  name: string;
15
16
  presence?: () => readonly { id: string; state: string }[];
16
- onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[] }) => void;
17
+ onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }) => void;
17
18
  onState?: (state: RoomLinkState) => void;
18
19
  application?: RoomLinkApplication;
20
+ refreshMs?: number;
21
+ now?: () => number;
19
22
  }
20
23
  /** Host-bound application ports; broker/config data never selects callbacks. */
21
24
  export interface RoomLinkContext {
@@ -54,6 +57,9 @@ const DIGEST = /^[A-Za-z0-9_-]{43}$/;
54
57
  const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
55
58
  interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
56
59
  interface Session {
60
+ closingHead?: PendingFrame;
61
+ refresh?: ReturnType<typeof setTimeout>;
62
+ refreshedAt?: number;
57
63
  processingFrame?: string;
58
64
  acknowledgedFrame?: string;
59
65
  pendingPresenceReplies: Set<string>;
@@ -115,6 +121,11 @@ function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePe
115
121
  /** One bounded, generation-fenced outbound connection. No callback receives body text or task data. */
116
122
  export class RoomLink {
117
123
  private running = false;
124
+ private stopping = false;
125
+ private stopWork?: Promise<void>;
126
+ private stoppedFrame?: { digest: string; resolve: () => void };
127
+ private readonly refreshMs: number;
128
+ private readonly applicationPauses = new Set<symbol>();
118
129
  private generation = 0;
119
130
  private session?: Session;
120
131
  private reconnect?: ReturnType<typeof setTimeout>;
@@ -129,10 +140,12 @@ export class RoomLink {
129
140
  private forcePresenceRequest = false;
130
141
  private republish?: ReturnType<typeof setTimeout>;
131
142
  private sendTail: Promise<void> = Promise.resolve();
132
- constructor(private readonly options: RoomLinkOptions) {}
143
+ constructor(private readonly options: RoomLinkOptions) { this.refreshMs = roomRefreshPeriod(options.refreshMs); }
133
144
 
134
145
  /** Advisory transport state without copying or decoding a room epoch for a status poll. */
135
146
  get connectionState(): RoomLinkState { return this.state; }
147
+ /** Negotiated transport evidence only; unknown until this session is ready. */
148
+ get livenessTracked(): boolean | undefined { return this.session?.phase === "ready" ? this.session.ephemeral : undefined; }
136
149
  status(): { state: RoomLinkState; members: number } {
137
150
  try {
138
151
  const row = this.options.store.get(this.options.name);
@@ -140,39 +153,105 @@ export class RoomLink {
140
153
  } catch { return { state: "offline", members: 0 }; }
141
154
  }
142
155
  async start(): Promise<void> {
143
- if (this.running) return;
156
+ if (this.running || this.stopping) return;
157
+ this.stopWork = undefined;
144
158
  this.running = true;
145
159
  const generation = ++this.generation;
146
160
  try { await assertRoomCryptoReady(); } catch { this.running = false; throw new RoomLinkError("unavailable"); }
147
161
  if (!this.running || generation !== this.generation) return;
148
162
  this.connect();
149
163
  }
150
- stop(): void {
151
- this.running = false;
164
+ stop(): Promise<void> {
165
+ if (this.stopWork) return this.stopWork;
166
+ this.running = false; this.stopping = true;
167
+ const session = this.session;
168
+ if (session && (session.frameSent || session.frameRetry)) session.closingHead = session.outbound[0];
152
169
  if (this.reconnect) clearTimeout(this.reconnect);
153
170
  this.reconnect = undefined;
154
- this.disconnect();
171
+ if (this.republish) clearTimeout(this.republish);
172
+ this.republish = undefined;
173
+ if (session?.refresh) clearTimeout(session.refresh);
174
+ if (session) {
175
+ // A storage control may already be waiting behind a slow application turn.
176
+ // Consume only those controls before sealing the final FIFO observation.
177
+ const queued = session.queue.splice(0); session.queuedBytes = 0;
178
+ for (const bytes of queued) this.receive(session, Uint8Array.from(bytes).buffer);
179
+ }
180
+ this.setState("offline");
181
+ let timer: ReturnType<typeof setTimeout> | undefined;
182
+ const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, ROOM_STOP_TIMEOUT_MS); });
183
+ // Storage controls are consumed directly during shutdown, outside the serial
184
+ // receive lane which may still be awaiting application work being fenced.
185
+ const final = session?.phase === "ready" ? this.finalPresence(session).catch(() => undefined) : Promise.resolve();
186
+ this.stopWork = Promise.race([final, deadline]).finally(() => {
187
+ if (timer) clearTimeout(timer);
188
+ this.disconnect(session); this.stoppedFrame?.resolve(); this.stoppedFrame = undefined; this.stopping = false;
189
+ });
190
+ return this.stopWork;
191
+ }
192
+ private async finalPresence(session: Session): Promise<void> {
193
+ await this.serialSend(async () => {
194
+ const row = this.current(session, undefined, true), identity = this.options.store.identity;
195
+ if (session.phase !== "ready" || session.roster?.digest !== row.current.digest) return;
196
+ const own = roomMembers(row.current).find(member => member.fingerprint === identity.public.fingerprint);
197
+ if (!own) fail("untrusted");
198
+ const recipients = roomMembers(row.current).map(member => member.fingerprint).filter(member => member !== identity.public.fingerprint).sort();
199
+ if (!recipients.length) return;
200
+ const peers = this.currentPresence(own.exposedPeers).map(peer => ({ ...peer, state: "offline" as const }));
201
+ const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers }), ...(session.ephemeral ? { ephemeral: true as const } : {}), sequence: this.options.store.sequence });
202
+ this.current(session, row.current.digest, true);
203
+ const digest = await this.frameDigest(wire);
204
+ this.current(session, row.current.digest, true);
205
+ // Unattempted RAM work can be withdrawn; durable application/receipt rows
206
+ // remain owned by the Store. An attempted head keeps its exact FIFO place.
207
+ const attempted = session.closingHead;
208
+ session.outbound = attempted ? [attempted] : [];
209
+ session.outboundBytes = attempted?.wire.byteLength ?? 0;
210
+ const stored = new Promise<void>(resolve => { this.stoppedFrame = { digest, resolve }; });
211
+ this.queueFrame(session, wire, digest, false,
212
+ () => { try { this.current(session, row.current.digest, true); return peers.every(peer => this.currentPresence(own.exposedPeers).some(value => value.id === peer.id)); } catch { return false; } }, true);
213
+ return stored;
214
+ });
155
215
  }
156
216
  refresh(): void {
217
+ if (this.stopping) return;
157
218
  if (this.reconnect) clearTimeout(this.reconnect);
158
219
  this.reconnect = undefined;
159
220
  this.disconnect();
160
221
  if (this.running) this.connect();
161
222
  }
223
+ private armPresenceRefresh(session: Session): void {
224
+ if (session.refresh) clearTimeout(session.refresh);
225
+ // Legacy brokers retain state-change/dedupe publication without periodic traffic.
226
+ if (!session.ephemeral || !this.running || this.stopping) return;
227
+ session.refreshedAt = (this.options.now ?? Date.now)();
228
+ session.refresh = setTimeout(() => {
229
+ session.refresh = undefined;
230
+ if (this.session !== session || !this.running || this.stopping || session.phase !== "ready") return;
231
+ const now = (this.options.now ?? Date.now)();
232
+ if (now - session.refreshedAt! >= this.refreshMs) {
233
+ session.pendingPresenceReplies.clear(); session.presenceAsked = false;
234
+ session.requestPresenceReply = true; this.forcePresenceRequest = true;
235
+ void this.publishPresence().catch(() => this.retry(session));
236
+ }
237
+ this.armPresenceRefresh(session);
238
+ }, this.refreshMs);
239
+ session.refresh.unref?.();
240
+ }
162
241
  private setState(state: RoomLinkState): void {
163
242
  if (this.state === state) return;
164
243
  this.state = state;
165
244
  try { this.options.onState?.(state); } catch { /* observation never changes transport authority */ }
166
245
  }
167
- private current(session: Session, digest?: string): RoomState {
168
- if (!this.running || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
246
+ private current(session: Session, digest?: string, final = false): RoomState {
247
+ if ((!this.running && !(final && this.stopping)) || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
169
248
  let row: RoomState | undefined;
170
249
  try { row = this.options.store.get(this.options.name); } catch { fail("unavailable"); }
171
250
  if (!row || row.left || row.current.room !== session.room || row.broker !== session.broker || (digest !== undefined && row.current.digest !== digest)) fail("state_changed");
172
251
  return row;
173
252
  }
174
253
  private isCurrent(session: Session, digest: string): boolean {
175
- try { this.current(session, digest); return true; } catch { return false; }
254
+ try { if (this.stopping) return false; this.current(session, digest); return true; } catch { return false; }
176
255
  }
177
256
  private publicationCurrent(session: Session, digest: string): boolean {
178
257
  return session.phase === "ready" && session.roster?.digest === digest && this.isCurrent(session, digest);
@@ -215,7 +294,7 @@ export class RoomLink {
215
294
  }
216
295
  let peers: RoomPresencePeer[] | undefined;
217
296
  try { peers = presence(entry.plaintext, member.exposedPeers); } catch { /* a rotation may have removed an exposed id since the durable receive */ }
218
- if (peers) { try { this.options.onPresence?.({ member: entry.sender, peers }); } catch { /* observation only */ } }
297
+ if (peers) { try { this.options.onPresence?.({ member: entry.sender, peers, restored: true }); } catch { /* observation only */ } }
219
298
  }
220
299
  // Presence is ephemeral: stale exposure/member metadata is discarded, never
221
300
  // replayed after a rotation or left at the head of recovery forever.
@@ -225,6 +304,7 @@ export class RoomLink {
225
304
  }
226
305
  }
227
306
  private async deliverApplication(session: Session, digest: string, input: Parameters<RoomLinkApplication["receive"]>[1]): Promise<void> {
307
+ if (!this.running || this.stopping) fail("state_changed");
228
308
  try { await this.options.application?.receive(this.context(session, digest), input); }
229
309
  catch (error) { if (!(error instanceof RoomApplicationDeferredError)) throw error; }
230
310
  this.current(session, digest);
@@ -254,6 +334,7 @@ export class RoomLink {
254
334
  this.republish = undefined;
255
335
  if (session) {
256
336
  clearTimeout(session.handshake);
337
+ if (session.refresh) clearTimeout(session.refresh);
257
338
  if (session.frameRetry) clearTimeout(session.frameRetry);
258
339
  session.outbound.length = 0; session.outboundBytes = 0; session.pendingPresenceReplies.clear();
259
340
  session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
@@ -262,6 +343,7 @@ export class RoomLink {
262
343
  this.setState("offline");
263
344
  }
264
345
  private retry(session?: Session): void {
346
+ if (this.stopping) return;
265
347
  if (session && this.session !== session) return;
266
348
  this.disconnect(session);
267
349
  if (!this.running || this.reconnect) return;
@@ -296,15 +378,15 @@ export class RoomLink {
296
378
  ws.addEventListener("error", () => this.retry(session));
297
379
  }
298
380
  private send(session: Session, bytes: Uint8Array): void {
299
- this.current(session);
381
+ this.current(session, undefined, this.stopping);
300
382
  if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
301
383
  session.ws.send(bytes);
302
384
  }
303
385
  private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
304
- this.current(session);
386
+ this.current(session, undefined, this.stopping);
305
387
  // Only an unsent observation may be replaced. Never reorder an attempted frame.
306
388
  if (presence) {
307
- const at = session.outbound.findIndex((frame, index) => frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
389
+ const at = session.outbound.findIndex((frame, index) => frame !== session.closingHead && frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
308
390
  if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
309
391
  }
310
392
  if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
@@ -313,8 +395,10 @@ export class RoomLink {
313
395
  }
314
396
  private pumpFrames(session: Session): void {
315
397
  if (session.frameSent || session.frameRetry || !session.outbound.length) return;
316
- this.current(session);
398
+ this.current(session, undefined, this.stopping);
317
399
  const frame = session.outbound[0]!;
400
+ // An expired backoff timer is not proof that a pre-stop attempt was stored.
401
+ if (this.stopping && frame === session.closingHead) return;
318
402
  if (frame.retained && !frame.retained()) {
319
403
  // Disposed/expired intent cannot survive in the transport's RAM queue.
320
404
  session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
@@ -336,9 +420,11 @@ export class RoomLink {
336
420
  private storedFrame(session: Session, digest: string): void {
337
421
  if (session.outbound[0]?.digest !== digest) return;
338
422
  const frame = session.outbound.shift()!; session.outboundBytes -= frame.wire.byteLength;
423
+ if (session.closingHead === frame) session.closingHead = undefined;
339
424
  session.frameSent = false; session.retryAttempt = 0;
340
425
  if (session.frameRetry) clearTimeout(session.frameRetry);
341
426
  session.frameRetry = undefined;
427
+ if (this.stoppedFrame?.digest === digest) this.stoppedFrame.resolve();
342
428
  this.pumpFrames(session);
343
429
  }
344
430
  private backoffFrame(session: Session, digest: string): void {
@@ -354,6 +440,22 @@ export class RoomLink {
354
440
  this.pumpFrames(session);
355
441
  }
356
442
  private receive(session: Session, data: unknown): void {
443
+ if (this.stopping && this.session === session) {
444
+ if (!(data instanceof ArrayBuffer) || data.byteLength > LIMITS.packetBytes) return;
445
+ try {
446
+ const row = exact(packet(new Uint8Array(data)), ["v", "type", "digest", "seq", "duplicate"]);
447
+ if (row.type === "stored" && typeof row.digest === "string" && DIGEST.test(row.digest)
448
+ && typeof row.seq === "string" && /^[1-9][0-9]{0,19}$/.test(row.seq) && typeof row.duplicate === "boolean") {
449
+ this.current(session, undefined, true);
450
+ if (this.options.store.receiptSendable(this.options.name, row.digest)) {
451
+ this.options.store.markStored(this.options.name, row.digest, row.seq);
452
+ this.options.store.retireStoredReceipt(this.options.name, row.digest);
453
+ }
454
+ this.storedFrame(session, row.digest);
455
+ }
456
+ } catch { /* Invalid controls cannot complete graceful shutdown. */ }
457
+ return;
458
+ }
357
459
  if (this.session !== session || !this.running) return;
358
460
  if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > LIMITS.packetBytes || session.queue.length + (session.deferredFrame ? 1 : 0) >= LIMITS.packets || session.queuedBytes + (session.deferredFrame?.byteLength ?? 0) + data.byteLength > LIMITS.queuedBytes) { this.retry(session); return; }
359
461
  const bytes = new Uint8Array(data).slice();
@@ -421,6 +523,8 @@ export class RoomLink {
421
523
  if (session.deferredFrame) {
422
524
  session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
423
525
  }
526
+ if (!this.running || this.stopping) return;
527
+ this.armPresenceRefresh(session);
424
528
  this.setState("online");
425
529
  const context = this.context(session, local.current.digest);
426
530
  this.options.application?.members(context);
@@ -602,6 +706,7 @@ export class RoomLink {
602
706
  this.pumpFrames(session);
603
707
  }
604
708
  private restoreReceipts(session: Session): void {
709
+ if (!this.running || this.stopping) return;
605
710
  this.current(session);
606
711
  this.options.store.expireReceipts(this.options.name);
607
712
  for (const entry of this.options.store.outbox(this.options.name)) {
@@ -632,12 +737,21 @@ export class RoomLink {
632
737
  this.sendTail = next.then(() => undefined, () => undefined);
633
738
  return next;
634
739
  }
740
+ /** Host-only admission pause; trust/current predicates remain independent. */
741
+ async quiesceApplications(): Promise<() => void> {
742
+ const token = Symbol(); this.applicationPauses.add(token);
743
+ await this.sendTail;
744
+ let released = false;
745
+ return () => { if (!released) { released = true; this.applicationPauses.delete(token); } };
746
+ }
635
747
  async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
636
748
  const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
637
749
  const session = this.session;
638
- if (!session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
750
+ if (this.stopping || !this.running || !session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
751
+ if (this.applicationPauses.size) { if (metadata) throw new RoomNotSentError(); fail("state_changed"); }
639
752
  const digest = this.current(session).current.digest;
640
753
  const allowed = () => this.publicationCurrent(session, digest) && current();
754
+ const admitted = () => this.applicationPauses.size === 0 && allowed();
641
755
  let queueStarted = false;
642
756
  try {
643
757
  let presenceWork: Promise<void> | undefined;
@@ -653,7 +767,7 @@ export class RoomLink {
653
767
  // Reserve this chat's FIFO slot before yielding to the earlier observation.
654
768
  return await this.serialSend(async () => {
655
769
  if (presenceWork) await presenceWork;
656
- if (!allowed()) fail("state_changed");
770
+ if (!admitted()) fail("state_changed");
657
771
  if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
658
772
  || session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
659
773
  if (message.type === "receipt") {
@@ -667,12 +781,12 @@ export class RoomLink {
667
781
  const row = this.current(session, digest);
668
782
  if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
669
783
  let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
670
- if (!allowed()) fail("state_changed");
784
+ if (!admitted()) fail("state_changed");
671
785
  let frameDigest: string;
672
786
  if (metadata) {
673
787
  queueStarted = true;
674
788
  let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
675
- try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, allowed); }
789
+ try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, admitted); }
676
790
  catch (error) {
677
791
  // An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
678
792
  if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
@@ -682,7 +796,7 @@ export class RoomLink {
682
796
  frameDigest = record.frameDigest ?? record.digest;
683
797
  } else if (message.type === "receipt") {
684
798
  const ttlMs = this.options.application?.receiptTtlMs?.(this.context(session, digest)) ?? RECEIPT_TTL_MS;
685
- const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, allowed);
799
+ const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, admitted);
686
800
  if (!allowed()) fail("state_changed");
687
801
  if (receipt.state === "accepted") return receipt.digest; // Existing broker-stored proof needs no pending wire slot.
688
802
  frameDigest = receipt.digest; wire = receipt.wire;
@@ -706,7 +820,7 @@ export class RoomLink {
706
820
  }
707
821
  async publishPresence(): Promise<void> {
708
822
  const session = this.session;
709
- if (!session || session.phase !== "ready") return;
823
+ if (!this.running || this.stopping || !session || session.phase !== "ready") return;
710
824
  if (this.publishing) { this.publishDirty = true; return; }
711
825
  // Keep the already scheduled coalesced snapshot. Cancelling it on every
712
826
  // state flip turns an awaited burst back into one durable seal per call.
@@ -714,9 +828,9 @@ export class RoomLink {
714
828
  if (this.republish) clearTimeout(this.republish);
715
829
  this.republish = undefined; this.publishDirty = false;
716
830
  const forceRequest = this.forcePresenceRequest;
717
- const response = this.replyPresence && !forceRequest;
831
+ const response = this.replyPresence;
718
832
  if (response) this.replyPresence = false;
719
- this.forcePresenceRequest = false;
833
+ this.forcePresenceRequest = response && forceRequest;
720
834
  let sent = false;
721
835
  this.publishing = true;
722
836
  try {
@@ -0,0 +1,11 @@
1
+ /** Host timing only; signed presence remains the existing v1 ids/state payload. */
2
+ export const ROOM_REFRESH_MS = 30_000;
3
+ export const ROOM_PRESENCE_MISSES = 3;
4
+ export const ROOM_STOP_TIMEOUT_MS = 2_000;
5
+
6
+ export function roomRefreshPeriod(value = ROOM_REFRESH_MS): number {
7
+ if (!Number.isSafeInteger(value) || value < 1 || value > Math.floor(2_147_483_647 / ROOM_PRESENCE_MISSES)) {
8
+ throw new Error("room refresh period is invalid; use a positive whole duration within the timer bound");
9
+ }
10
+ return value;
11
+ }