@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/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +1 -0
- package/docs/agent-notes/tests.md +2 -2
- package/docs/design/bus.md +41 -2
- package/docs/dogfood.md +1 -0
- package/docs/operations.md +54 -6
- package/docs/quickstart.md +6 -0
- package/docs/security.md +24 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +44 -1
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +55 -1
- package/docs/verification/2026-11-06-0.32.0.md +149 -0
- package/docs/verified.json +11 -11
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/cli/console-state.ts +1 -1
- package/src/cli/room.ts +20 -10
- package/src/hub/bus.ts +18 -0
- package/src/hub/daemon.ts +7 -5
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +136 -22
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/service.ts +142 -18
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +10 -0
- package/src/ui/index.html +4 -2
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.
|
|
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.
|
|
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 (!
|
|
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 (!
|
|
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,
|
|
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 },
|
|
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
|
|
831
|
+
const response = this.replyPresence;
|
|
718
832
|
if (response) this.replyPresence = false;
|
|
719
|
-
this.forcePresenceRequest =
|
|
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
|
+
}
|