@staix/agent-hub 0.31.0 → 0.33.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 +33 -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 +109 -4
- package/docs/dogfood.md +2 -0
- package/docs/memory.md +9 -3
- package/docs/operations.md +98 -17
- package/docs/quickstart.md +18 -2
- package/docs/security.md +52 -9
- package/docs/specs/2026-09-19-agent-hub-design.md +75 -5
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
- 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/verification/2026-11-06-0.33.0.md +222 -0
- package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
- package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
- package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
- package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
- package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +11 -11
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +528 -206
- package/src/adapters/compat.ts +11 -11
- package/src/broker/protocol.ts +5 -0
- package/src/broker/server.ts +54 -4
- package/src/broker/store.ts +6 -3
- package/src/cli/console-state.ts +1 -1
- package/src/cli/output.ts +5 -1
- package/src/cli/room.ts +34 -12
- package/src/cli/status-lines.ts +8 -1
- package/src/cli/terminal-recovery.ts +37 -15
- package/src/hub/bus.ts +25 -0
- package/src/hub/daemon.ts +20 -9
- package/src/rooms/application.ts +15 -0
- package/src/rooms/commands.ts +135 -30
- package/src/rooms/health.ts +32 -0
- package/src/rooms/link.ts +394 -56
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/remote-peer.ts +18 -3
- package/src/rooms/service.ts +167 -28
- package/src/rooms/status.ts +65 -2
- package/src/rooms/store.ts +30 -1
- package/src/rooms/wire.ts +10 -8
- package/src/ui/index.html +5 -3
package/src/rooms/link.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/** Outbound room transport. The local bus and application delivery boundary belong to X4. */
|
|
2
|
-
import { createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "../broker/protocol.ts";
|
|
2
|
+
import { BROKER_CODEC_FEATURES_OPT_IN, createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "../broker/protocol.ts";
|
|
3
3
|
import { assertRoomCryptoReady, encodeBase64url, sha256, RoomCryptoError } from "./crypto.ts";
|
|
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 {
|
|
7
|
+
import { ROOM_STOP_TIMEOUT_MS, roomRefreshPeriod } from "./liveness.ts";
|
|
8
|
+
import { roomHealthCause, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
|
|
9
|
+
import { ROOM_CODEC_FEATURES, roomCodecFeatures, roomRequiredCodecFeature, decodeRoomMessage, encodeRoomMessage, type RoomCodecFeature, type RoomMessage } from "./application.ts";
|
|
8
10
|
|
|
9
11
|
export type RoomLinkState = "offline" | "connecting" | "online";
|
|
10
12
|
export type RoomPresenceState = "idle" | "busy" | "paused" | "offline";
|
|
@@ -13,9 +15,11 @@ export interface RoomLinkOptions {
|
|
|
13
15
|
store: RoomStore;
|
|
14
16
|
name: string;
|
|
15
17
|
presence?: () => readonly { id: string; state: string }[];
|
|
16
|
-
onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[] }) => void;
|
|
18
|
+
onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true; observedAt?: number | null }) => void;
|
|
17
19
|
onState?: (state: RoomLinkState) => void;
|
|
18
20
|
application?: RoomLinkApplication;
|
|
21
|
+
refreshMs?: number;
|
|
22
|
+
now?: () => number;
|
|
19
23
|
}
|
|
20
24
|
/** Host-bound application ports; broker/config data never selects callbacks. */
|
|
21
25
|
export interface RoomLinkContext {
|
|
@@ -30,7 +34,7 @@ export interface RoomLinkApplication {
|
|
|
30
34
|
transportAck?(context: RoomLinkContext, input: { digest: string }): void;
|
|
31
35
|
receiptTtlMs?(context: RoomLinkContext): number;
|
|
32
36
|
capacityChanged?(context: RoomLinkContext): void;
|
|
33
|
-
|
|
37
|
+
codecsChanged?(context: RoomLinkContext, input: { member: string; features?: readonly RoomCodecFeature[] }): void;
|
|
34
38
|
}
|
|
35
39
|
/** Host port signal: no application claim/effect occurred; the durable plaintext remains pending. */
|
|
36
40
|
export class RoomApplicationDeferredError extends Error {
|
|
@@ -42,18 +46,36 @@ export class RoomLinkError extends Error {
|
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
/** Only before durable application enqueue: the Bus may retain its original queue item. */
|
|
49
|
+
export class RoomCodecUnsupportedError extends Error {
|
|
50
|
+
readonly code = "codec_unsupported";
|
|
51
|
+
constructor(readonly feature: RoomCodecFeature) {
|
|
52
|
+
super(`Room send refused: recipient codec support for ${feature} is unavailable; upgrade both hubs and the broker, reconnect the room, then retry.`);
|
|
53
|
+
this.name = "RoomCodecUnsupportedError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
export class RoomNotSentError extends Error {
|
|
46
57
|
readonly code = "not_sent";
|
|
47
58
|
constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
|
|
48
59
|
}
|
|
60
|
+
/** No compatibility observation exists yet; retain work under its original queue deadline. */
|
|
61
|
+
export class RoomCodecPendingError extends RoomNotSentError {
|
|
62
|
+
constructor(readonly changed: () => boolean) {
|
|
63
|
+
super(); this.name = "RoomCodecPendingError";
|
|
64
|
+
this.message = "Room codec observation is pending; wait for a signed codec advertisement, then retry.";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export type RoomCodecState = "pending" | "supported" | "unsupported";
|
|
49
68
|
const RECEIPT_TTL_MS = 24 * 3_600_000;
|
|
50
69
|
const LIMITS = Object.freeze({ packets: 8, queuedBytes: 2_097_152, packetBytes: 1_048_576, backpressure: 1_048_576, handshakeMs: 30_000, reconnectMaxMs: 300_000 });
|
|
51
70
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
52
71
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
53
72
|
const DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
54
73
|
const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
|
|
55
|
-
interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
|
|
74
|
+
interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string; codecFeatures?: boolean; supported?: () => boolean; codecPending?: () => boolean; codecTarget?: { member: string; feature: RoomCodecFeature } }
|
|
56
75
|
interface Session {
|
|
76
|
+
closingHead?: PendingFrame;
|
|
77
|
+
refresh?: ReturnType<typeof setTimeout>;
|
|
78
|
+
refreshedAt?: number;
|
|
57
79
|
processingFrame?: string;
|
|
58
80
|
acknowledgedFrame?: string;
|
|
59
81
|
pendingPresenceReplies: Set<string>;
|
|
@@ -66,6 +88,13 @@ interface Session {
|
|
|
66
88
|
frameRetry?: ReturnType<typeof setTimeout>;
|
|
67
89
|
lastPresence?: string;
|
|
68
90
|
ephemeral?: boolean;
|
|
91
|
+
codecFeatures?: boolean;
|
|
92
|
+
codecSession?: string;
|
|
93
|
+
codecPublishing?: Promise<void>;
|
|
94
|
+
codecDirty?: boolean;
|
|
95
|
+
pendingPresence?: Map<string, { session: string | null; epoch: string; peers: RoomPresencePeer[]; observedAt: number | null }>;
|
|
96
|
+
codecPeers?: Map<string, { session: string | null; supported: boolean }>;
|
|
97
|
+
features?: Map<string, { session: string; features: readonly string[] }>;
|
|
69
98
|
ws: WebSocket;
|
|
70
99
|
generation: number;
|
|
71
100
|
room: string;
|
|
@@ -100,7 +129,8 @@ function packet(bytes: Uint8Array): Record<string, unknown> {
|
|
|
100
129
|
return row;
|
|
101
130
|
}
|
|
102
131
|
function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePeer[] {
|
|
103
|
-
const decoded = packet(bytes), row = exact(decoded, Object.hasOwn(decoded, "requestReply") ? ["
|
|
132
|
+
const decoded = packet(bytes), row = exact(decoded, ["v", "type", "peers", ...(Object.hasOwn(decoded, "requestReply") ? ["requestReply"] : []), ...(Object.hasOwn(decoded, "features") ? ["features", "codecSession"] : [])]);
|
|
133
|
+
if (Object.hasOwn(row, "features")) { roomCodecFeatures(row.features); if (typeof row.codecSession !== "string" || !DIGEST.test(row.codecSession)) fail(); }
|
|
104
134
|
if (row.requestReply !== undefined && typeof row.requestReply !== "boolean") fail();
|
|
105
135
|
if (row.type !== "presence" || !Array.isArray(row.peers) || row.peers.length > 64) fail();
|
|
106
136
|
const peers = row.peers.map(value => {
|
|
@@ -115,11 +145,19 @@ function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePe
|
|
|
115
145
|
/** One bounded, generation-fenced outbound connection. No callback receives body text or task data. */
|
|
116
146
|
export class RoomLink {
|
|
117
147
|
private running = false;
|
|
148
|
+
private stopping = false;
|
|
149
|
+
private stopWork?: Promise<void>;
|
|
150
|
+
private stoppedFrame?: { digest: string; resolve: () => void };
|
|
151
|
+
private readonly refreshMs: number;
|
|
152
|
+
private readonly applicationPauses = new Set<symbol>();
|
|
118
153
|
private generation = 0;
|
|
119
154
|
private session?: Session;
|
|
120
155
|
private reconnect?: ReturnType<typeof setTimeout>;
|
|
121
156
|
private attempts = 0;
|
|
122
157
|
private state: RoomLinkState = "offline";
|
|
158
|
+
private cause?: RoomHealthCause;
|
|
159
|
+
private blockedCodec?: { member: string; feature: RoomCodecFeature };
|
|
160
|
+
private clockHighWater?: number;
|
|
123
161
|
private publishing = false;
|
|
124
162
|
private publishDirty = false;
|
|
125
163
|
private replyPresence = false;
|
|
@@ -129,50 +167,158 @@ export class RoomLink {
|
|
|
129
167
|
private forcePresenceRequest = false;
|
|
130
168
|
private republish?: ReturnType<typeof setTimeout>;
|
|
131
169
|
private sendTail: Promise<void> = Promise.resolve();
|
|
132
|
-
constructor(private readonly options: RoomLinkOptions) {}
|
|
170
|
+
constructor(private readonly options: RoomLinkOptions) { this.refreshMs = roomRefreshPeriod(options.refreshMs); }
|
|
133
171
|
|
|
134
172
|
/** Advisory transport state without copying or decoding a room epoch for a status poll. */
|
|
135
173
|
get connectionState(): RoomLinkState { return this.state; }
|
|
136
|
-
|
|
174
|
+
get connectionCause(): RoomHealthCause | undefined { return this.cause; }
|
|
175
|
+
/** Negotiated transport evidence only; unknown until this session is ready. */
|
|
176
|
+
codecState(recipient: string, feature: RoomCodecFeature): RoomCodecState {
|
|
177
|
+
const session = this.session;
|
|
178
|
+
if (!session || session.phase !== "ready") return "pending";
|
|
179
|
+
if (session.codecFeatures !== true) return "unsupported";
|
|
180
|
+
const peer = session.codecPeers?.get(recipient);
|
|
181
|
+
if (!peer || peer.session === null) return "pending";
|
|
182
|
+
if (!peer.supported) return "unsupported";
|
|
183
|
+
const advertised = session.features?.get(recipient);
|
|
184
|
+
if (!advertised || advertised.session !== peer.session) return "pending";
|
|
185
|
+
return advertised.features.includes(feature) ? "supported" : "unsupported";
|
|
186
|
+
}
|
|
187
|
+
codecSupported(recipient: string, feature: RoomCodecFeature): boolean { return this.codecState(recipient, feature) === "supported"; }
|
|
188
|
+
private codecRefusal(recipient: string, feature: RoomCodecFeature): RoomNotSentError | RoomCodecUnsupportedError {
|
|
189
|
+
if (this.codecState(recipient, feature) === "unsupported") return new RoomCodecUnsupportedError(feature);
|
|
190
|
+
const session = this.session, peer = session?.codecPeers?.get(recipient), advertisement = session?.features?.get(recipient);
|
|
191
|
+
return new RoomCodecPendingError(() => this.session !== session || session?.codecPeers?.get(recipient) !== peer
|
|
192
|
+
|| session?.features?.get(recipient) !== advertisement || this.codecState(recipient, feature) !== "pending");
|
|
193
|
+
}
|
|
194
|
+
get livenessTracked(): boolean | undefined { return this.session?.phase === "ready" ? this.session.ephemeral : undefined; }
|
|
195
|
+
status(): { state: RoomLinkState; members: number; cause?: RoomHealthCause; nextStep?: string } {
|
|
137
196
|
try {
|
|
138
197
|
const row = this.options.store.get(this.options.name);
|
|
139
|
-
return { state: this.state, members: row && !row.left ? roomMembers(row.current).length : 0
|
|
140
|
-
|
|
198
|
+
return { state: this.state, members: row && !row.left ? roomMembers(row.current).length : 0,
|
|
199
|
+
...(this.cause ? { cause: this.cause, nextStep: roomHealthNextStep(this.cause) } : {}) };
|
|
200
|
+
} catch (error) { const cause = roomHealthCause(error); return { state: "offline", members: 0, cause, nextStep: roomHealthNextStep(cause) }; }
|
|
141
201
|
}
|
|
142
202
|
async start(): Promise<void> {
|
|
143
|
-
if (this.running) return;
|
|
203
|
+
if (this.running || this.stopping) return;
|
|
204
|
+
this.stopWork = undefined;
|
|
144
205
|
this.running = true;
|
|
145
206
|
const generation = ++this.generation;
|
|
146
207
|
try { await assertRoomCryptoReady(); } catch { this.running = false; throw new RoomLinkError("unavailable"); }
|
|
147
208
|
if (!this.running || generation !== this.generation) return;
|
|
148
209
|
this.connect();
|
|
149
210
|
}
|
|
150
|
-
stop(): void {
|
|
151
|
-
this.
|
|
211
|
+
stop(): Promise<void> {
|
|
212
|
+
if (this.stopWork) return this.stopWork;
|
|
213
|
+
this.running = false; this.stopping = true;
|
|
214
|
+
const session = this.session;
|
|
215
|
+
if (session && (session.frameSent || session.frameRetry)) session.closingHead = session.outbound[0];
|
|
152
216
|
if (this.reconnect) clearTimeout(this.reconnect);
|
|
153
217
|
this.reconnect = undefined;
|
|
154
|
-
this.
|
|
218
|
+
if (this.republish) clearTimeout(this.republish);
|
|
219
|
+
this.republish = undefined;
|
|
220
|
+
if (session?.refresh) clearTimeout(session.refresh);
|
|
221
|
+
if (session) {
|
|
222
|
+
// A storage control may already be waiting behind a slow application turn.
|
|
223
|
+
// Consume only those controls before sealing the final FIFO observation.
|
|
224
|
+
const queued = session.queue.splice(0); session.queuedBytes = 0;
|
|
225
|
+
for (const bytes of queued) this.receive(session, Uint8Array.from(bytes).buffer);
|
|
226
|
+
}
|
|
227
|
+
this.setState("offline");
|
|
228
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
229
|
+
const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, ROOM_STOP_TIMEOUT_MS); });
|
|
230
|
+
// Storage controls are consumed directly during shutdown, outside the serial
|
|
231
|
+
// receive lane which may still be awaiting application work being fenced.
|
|
232
|
+
const final = session?.phase === "ready" ? this.finalPresence(session).catch(() => undefined) : Promise.resolve();
|
|
233
|
+
this.stopWork = Promise.race([final, deadline]).finally(() => {
|
|
234
|
+
if (timer) clearTimeout(timer);
|
|
235
|
+
this.disconnect(session); this.stoppedFrame?.resolve(); this.stoppedFrame = undefined; this.stopping = false;
|
|
236
|
+
});
|
|
237
|
+
return this.stopWork;
|
|
238
|
+
}
|
|
239
|
+
private async finalPresence(session: Session): Promise<void> {
|
|
240
|
+
await this.serialSend(async () => {
|
|
241
|
+
const row = this.current(session, undefined, true), identity = this.options.store.identity;
|
|
242
|
+
if (session.phase !== "ready" || session.roster?.digest !== row.current.digest) return;
|
|
243
|
+
const own = roomMembers(row.current).find(member => member.fingerprint === identity.public.fingerprint);
|
|
244
|
+
if (!own) fail("untrusted");
|
|
245
|
+
const recipients = roomMembers(row.current).map(member => member.fingerprint).filter(member => member !== identity.public.fingerprint).sort();
|
|
246
|
+
if (!recipients.length) return;
|
|
247
|
+
const peers = this.currentPresence(own.exposedPeers).map(peer => ({ ...peer, state: "offline" as const }));
|
|
248
|
+
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 });
|
|
249
|
+
this.current(session, row.current.digest, true);
|
|
250
|
+
const digest = await this.frameDigest(wire);
|
|
251
|
+
this.current(session, row.current.digest, true);
|
|
252
|
+
// Unattempted RAM work can be withdrawn; durable application/receipt rows
|
|
253
|
+
// remain owned by the Store. An attempted head keeps its exact FIFO place.
|
|
254
|
+
const attempted = session.closingHead;
|
|
255
|
+
session.outbound = attempted ? [attempted] : [];
|
|
256
|
+
session.outboundBytes = attempted?.wire.byteLength ?? 0;
|
|
257
|
+
const stored = new Promise<void>(resolve => { this.stoppedFrame = { digest, resolve }; });
|
|
258
|
+
this.queueFrame(session, wire, digest, false,
|
|
259
|
+
() => { 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);
|
|
260
|
+
return stored;
|
|
261
|
+
});
|
|
155
262
|
}
|
|
156
263
|
refresh(): void {
|
|
264
|
+
if (this.stopping) return;
|
|
157
265
|
if (this.reconnect) clearTimeout(this.reconnect);
|
|
158
266
|
this.reconnect = undefined;
|
|
159
267
|
this.disconnect();
|
|
160
268
|
if (this.running) this.connect();
|
|
161
269
|
}
|
|
162
|
-
private
|
|
163
|
-
if (
|
|
164
|
-
|
|
270
|
+
private armPresenceRefresh(session: Session): void {
|
|
271
|
+
if (session.refresh) clearTimeout(session.refresh);
|
|
272
|
+
// Legacy brokers retain state-change/dedupe publication without periodic traffic.
|
|
273
|
+
if (!session.ephemeral || !this.running || this.stopping) return;
|
|
274
|
+
session.refreshedAt = this.observationTime();
|
|
275
|
+
session.refresh = setTimeout(() => {
|
|
276
|
+
session.refresh = undefined;
|
|
277
|
+
if (this.session !== session || !this.running || this.stopping || session.phase !== "ready") return;
|
|
278
|
+
const now = this.observationTime();
|
|
279
|
+
if (now !== undefined && session.refreshedAt !== undefined && now - session.refreshedAt >= this.refreshMs) {
|
|
280
|
+
session.pendingPresenceReplies.clear(); session.presenceAsked = false;
|
|
281
|
+
session.requestPresenceReply = true; this.forcePresenceRequest = true;
|
|
282
|
+
void this.publishPresence().catch(() => this.retry(session));
|
|
283
|
+
}
|
|
284
|
+
this.armPresenceRefresh(session);
|
|
285
|
+
}, this.refreshMs);
|
|
286
|
+
session.refresh.unref?.();
|
|
287
|
+
}
|
|
288
|
+
private observationTime(): number | undefined {
|
|
289
|
+
let now: number;
|
|
290
|
+
try { now = (this.options.now ?? Date.now)(); } catch { this.setState(this.state, "unavailable"); return undefined; }
|
|
291
|
+
if (!Number.isSafeInteger(now) || now < 0) { this.setState(this.state, "unavailable"); return undefined; }
|
|
292
|
+
if (this.clockHighWater !== undefined && now < this.clockHighWater) { this.setState(this.state, "clock_rollback"); return undefined; }
|
|
293
|
+
this.clockHighWater = now;
|
|
294
|
+
return now;
|
|
295
|
+
}
|
|
296
|
+
/** Called only after the Store-backed sequence port committed a reservation. */
|
|
297
|
+
private clockRecoveredAfterWrite(): void {
|
|
298
|
+
if (this.cause === "clock_rollback" && this.observationTime() !== undefined) this.setState(this.state);
|
|
299
|
+
}
|
|
300
|
+
private refreshCodecCause(member: string): void {
|
|
301
|
+
const blocked = this.blockedCodec;
|
|
302
|
+
if (!blocked || blocked.member !== member) return;
|
|
303
|
+
const compatibility = this.codecState(member, blocked.feature);
|
|
304
|
+
if (compatibility === "supported") this.setState(this.state);
|
|
305
|
+
else this.setState(this.state, compatibility === "pending" ? "codec_pending" : "codec_unsupported");
|
|
306
|
+
}
|
|
307
|
+
private setState(state: RoomLinkState, cause?: RoomHealthCause): void {
|
|
308
|
+
if (cause !== "codec_pending" && cause !== "codec_unsupported") this.blockedCodec = undefined;
|
|
309
|
+
if (this.state === state && this.cause === cause) return;
|
|
310
|
+
this.state = state; this.cause = cause;
|
|
165
311
|
try { this.options.onState?.(state); } catch { /* observation never changes transport authority */ }
|
|
166
312
|
}
|
|
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");
|
|
313
|
+
private current(session: Session, digest?: string, final = false): RoomState {
|
|
314
|
+
if ((!this.running && !(final && this.stopping)) || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
|
|
169
315
|
let row: RoomState | undefined;
|
|
170
|
-
try { row = this.options.store.get(this.options.name); } catch { fail("unavailable"); }
|
|
316
|
+
try { row = this.options.store.get(this.options.name); } catch (error) { this.setState(this.state, roomHealthCause(error)); fail("unavailable"); }
|
|
171
317
|
if (!row || row.left || row.current.room !== session.room || row.broker !== session.broker || (digest !== undefined && row.current.digest !== digest)) fail("state_changed");
|
|
172
318
|
return row;
|
|
173
319
|
}
|
|
174
320
|
private isCurrent(session: Session, digest: string): boolean {
|
|
175
|
-
try { this.current(session, digest); return true; } catch { return false; }
|
|
321
|
+
try { if (this.stopping) return false; this.current(session, digest); return true; } catch { return false; }
|
|
176
322
|
}
|
|
177
323
|
private publicationCurrent(session: Session, digest: string): boolean {
|
|
178
324
|
return session.phase === "ready" && session.roster?.digest === digest && this.isCurrent(session, digest);
|
|
@@ -215,7 +361,7 @@ export class RoomLink {
|
|
|
215
361
|
}
|
|
216
362
|
let peers: RoomPresencePeer[] | undefined;
|
|
217
363
|
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 */ } }
|
|
364
|
+
if (peers) { try { this.options.onPresence?.({ member: entry.sender, peers, restored: true }); } catch { /* observation only */ } }
|
|
219
365
|
}
|
|
220
366
|
// Presence is ephemeral: stale exposure/member metadata is discarded, never
|
|
221
367
|
// replayed after a rotation or left at the head of recovery forever.
|
|
@@ -225,6 +371,7 @@ export class RoomLink {
|
|
|
225
371
|
}
|
|
226
372
|
}
|
|
227
373
|
private async deliverApplication(session: Session, digest: string, input: Parameters<RoomLinkApplication["receive"]>[1]): Promise<void> {
|
|
374
|
+
if (!this.running || this.stopping) fail("state_changed");
|
|
228
375
|
try { await this.options.application?.receive(this.context(session, digest), input); }
|
|
229
376
|
catch (error) { if (!(error instanceof RoomApplicationDeferredError)) throw error; }
|
|
230
377
|
this.current(session, digest);
|
|
@@ -244,7 +391,7 @@ export class RoomLink {
|
|
|
244
391
|
}).catch(() => undefined);
|
|
245
392
|
return work;
|
|
246
393
|
}
|
|
247
|
-
private disconnect(session = this.session): void {
|
|
394
|
+
private disconnect(session = this.session, cause?: RoomHealthCause): void {
|
|
248
395
|
if (session && session !== this.session) return;
|
|
249
396
|
this.generation++;
|
|
250
397
|
this.session = undefined;
|
|
@@ -254,16 +401,26 @@ export class RoomLink {
|
|
|
254
401
|
this.republish = undefined;
|
|
255
402
|
if (session) {
|
|
256
403
|
clearTimeout(session.handshake);
|
|
404
|
+
if (session.refresh) clearTimeout(session.refresh);
|
|
257
405
|
if (session.frameRetry) clearTimeout(session.frameRetry);
|
|
258
406
|
session.outbound.length = 0; session.outboundBytes = 0; session.pendingPresenceReplies.clear();
|
|
259
407
|
session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
|
|
260
408
|
try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
|
|
261
409
|
}
|
|
262
|
-
this.setState("offline");
|
|
410
|
+
this.setState("offline", cause);
|
|
263
411
|
}
|
|
264
|
-
private retry(session?: Session): void {
|
|
412
|
+
private retry(session?: Session, error?: unknown): void {
|
|
413
|
+
if (this.stopping) return;
|
|
265
414
|
if (session && this.session !== session) return;
|
|
266
|
-
|
|
415
|
+
let cause: RoomHealthCause = "transport_closed";
|
|
416
|
+
if (error instanceof RoomStoreError) cause = roomHealthCause(error);
|
|
417
|
+
else if (this.cause === "broker_refused" || this.cause === "clock_rollback") cause = this.cause;
|
|
418
|
+
else if (error instanceof RoomLinkError) {
|
|
419
|
+
if (error.code === "limit") cause = "backpressure";
|
|
420
|
+
else if (error.code === "invalid_wire") cause = "untrusted";
|
|
421
|
+
else cause = error.code;
|
|
422
|
+
} else if (this.cause === "backpressure") cause = this.cause;
|
|
423
|
+
this.disconnect(session, cause);
|
|
267
424
|
if (!this.running || this.reconnect) return;
|
|
268
425
|
const ceiling = Math.min(LIMITS.reconnectMaxMs, 1000 * 2 ** Math.min(this.attempts++, 9));
|
|
269
426
|
const delay = Math.floor(ceiling / 2 + Math.random() * ceiling / 2);
|
|
@@ -273,48 +430,50 @@ export class RoomLink {
|
|
|
273
430
|
private connect(): void {
|
|
274
431
|
if (!this.running || this.session) return;
|
|
275
432
|
let row: RoomState | undefined;
|
|
276
|
-
try { row = this.options.store.get(this.options.name); } catch { this.
|
|
433
|
+
try { row = this.options.store.get(this.options.name); } catch (error) { this.retry(undefined, error); return; }
|
|
277
434
|
if (!row || row.left) { this.setState("offline"); return; }
|
|
278
435
|
let url: URL;
|
|
279
436
|
try {
|
|
280
437
|
url = new URL(brokerUrl(row.broker));
|
|
281
438
|
if (url.pathname !== "/" || url.search) fail("untrusted");
|
|
282
|
-
} catch { this.setState("offline"); return; }
|
|
439
|
+
} catch { this.setState("offline", "untrusted"); return; }
|
|
283
440
|
url.pathname = `/v1/rooms/${row.current.room}`;
|
|
284
441
|
let ws: WebSocket;
|
|
285
|
-
//
|
|
286
|
-
try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1" } }); } catch { this.retry(); return; }
|
|
442
|
+
// Preserve the released ephemeral header exactly; old brokers ignore the additional codec token.
|
|
443
|
+
try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1", "x-agent-hub-codecs": BROKER_CODEC_FEATURES_OPT_IN } }); } catch { this.retry(); return; }
|
|
287
444
|
ws.binaryType = "arraybuffer";
|
|
288
445
|
const generation = ++this.generation;
|
|
289
446
|
const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", pendingPresenceReplies: new Set(), presenceAsked: false, outbound: [], outboundBytes: 0, frameSent: false, retryAttempt: 0, queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
|
|
290
447
|
handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
|
|
291
448
|
session.handshake.unref?.();
|
|
292
449
|
this.session = session;
|
|
293
|
-
this.setState("connecting");
|
|
450
|
+
this.setState("connecting", "connecting");
|
|
294
451
|
ws.addEventListener("message", event => this.receive(session, event.data));
|
|
295
452
|
ws.addEventListener("close", () => this.retry(session));
|
|
296
453
|
ws.addEventListener("error", () => this.retry(session));
|
|
297
454
|
}
|
|
298
455
|
private send(session: Session, bytes: Uint8Array): void {
|
|
299
|
-
this.current(session);
|
|
456
|
+
this.current(session, undefined, this.stopping);
|
|
300
457
|
if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
|
|
301
458
|
session.ws.send(bytes);
|
|
302
459
|
}
|
|
303
|
-
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);
|
|
460
|
+
private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string, codecFeatures = false, supported?: () => boolean, codecPending?: () => boolean, codecTarget?: { member: string; feature: RoomCodecFeature }): void {
|
|
461
|
+
this.current(session, undefined, this.stopping);
|
|
305
462
|
// Only an unsent observation may be replaced. Never reorder an attempted frame.
|
|
306
463
|
if (presence) {
|
|
307
|
-
const at = session.outbound.findIndex((frame, index) => frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
|
|
464
|
+
const at = session.outbound.findIndex((frame, index) => frame !== session.closingHead && frame.presence && !!frame.codecFeatures === codecFeatures && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
|
|
308
465
|
if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
|
|
309
466
|
}
|
|
310
467
|
if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
|
|
311
|
-
session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck }); session.outboundBytes += wire.byteLength;
|
|
468
|
+
session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck, codecFeatures, supported, codecPending, codecTarget }); session.outboundBytes += wire.byteLength;
|
|
312
469
|
this.pumpFrames(session);
|
|
313
470
|
}
|
|
314
471
|
private pumpFrames(session: Session): void {
|
|
315
472
|
if (session.frameSent || session.frameRetry || !session.outbound.length) return;
|
|
316
|
-
this.current(session);
|
|
473
|
+
this.current(session, undefined, this.stopping);
|
|
317
474
|
const frame = session.outbound[0]!;
|
|
475
|
+
// An expired backoff timer is not proof that a pre-stop attempt was stored.
|
|
476
|
+
if (this.stopping && frame === session.closingHead) return;
|
|
318
477
|
if (frame.retained && !frame.retained()) {
|
|
319
478
|
// Disposed/expired intent cannot survive in the transport's RAM queue.
|
|
320
479
|
session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
|
|
@@ -327,23 +486,39 @@ export class RoomLink {
|
|
|
327
486
|
void this.publishPresence().catch(() => this.retry(session));
|
|
328
487
|
return;
|
|
329
488
|
}
|
|
489
|
+
if (frame.supported && !frame.supported()) {
|
|
490
|
+
this.blockedCodec = frame.codecTarget;
|
|
491
|
+
this.setState(this.state, frame.codecPending?.() ? "codec_pending" : "codec_unsupported");
|
|
492
|
+
if (frame.retained) {
|
|
493
|
+
// The durable intent owns retry. Withdraw only this unsent ciphertext so
|
|
494
|
+
// later receipts can progress; a fresh codec observation reseals its retry.
|
|
495
|
+
session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
|
|
496
|
+
this.pumpFrames(session);
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
330
500
|
// A foreground receipt follows the chat ACK on the socket. Otherwise broker
|
|
331
501
|
// verification/storage of the receipt can pin the chat's one-packet slot.
|
|
332
502
|
if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
|
|
333
503
|
this.send(session, frame.wire); session.frameSent = true;
|
|
504
|
+
if (frame.codecTarget) this.refreshCodecCause(frame.codecTarget.member);
|
|
334
505
|
const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
|
|
335
506
|
}
|
|
336
507
|
private storedFrame(session: Session, digest: string): void {
|
|
337
508
|
if (session.outbound[0]?.digest !== digest) return;
|
|
338
509
|
const frame = session.outbound.shift()!; session.outboundBytes -= frame.wire.byteLength;
|
|
510
|
+
if (session.closingHead === frame) session.closingHead = undefined;
|
|
339
511
|
session.frameSent = false; session.retryAttempt = 0;
|
|
340
512
|
if (session.frameRetry) clearTimeout(session.frameRetry);
|
|
341
513
|
session.frameRetry = undefined;
|
|
514
|
+
if (this.cause === "backpressure") this.setState(this.state);
|
|
515
|
+
if (this.stoppedFrame?.digest === digest) this.stoppedFrame.resolve();
|
|
342
516
|
this.pumpFrames(session);
|
|
343
517
|
}
|
|
344
518
|
private backoffFrame(session: Session, digest: string): void {
|
|
345
519
|
if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
|
|
346
520
|
session.frameSent = false;
|
|
521
|
+
this.setState(this.state, "backpressure");
|
|
347
522
|
const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
|
|
348
523
|
session.frameRetry = setTimeout(() => {
|
|
349
524
|
session.frameRetry = undefined;
|
|
@@ -354,6 +529,22 @@ export class RoomLink {
|
|
|
354
529
|
this.pumpFrames(session);
|
|
355
530
|
}
|
|
356
531
|
private receive(session: Session, data: unknown): void {
|
|
532
|
+
if (this.stopping && this.session === session) {
|
|
533
|
+
if (!(data instanceof ArrayBuffer) || data.byteLength > LIMITS.packetBytes) return;
|
|
534
|
+
try {
|
|
535
|
+
const row = exact(packet(new Uint8Array(data)), ["v", "type", "digest", "seq", "duplicate"]);
|
|
536
|
+
if (row.type === "stored" && typeof row.digest === "string" && DIGEST.test(row.digest)
|
|
537
|
+
&& typeof row.seq === "string" && /^[1-9][0-9]{0,19}$/.test(row.seq) && typeof row.duplicate === "boolean") {
|
|
538
|
+
this.current(session, undefined, true);
|
|
539
|
+
if (this.options.store.receiptSendable(this.options.name, row.digest)) {
|
|
540
|
+
this.options.store.markStored(this.options.name, row.digest, row.seq);
|
|
541
|
+
this.options.store.retireStoredReceipt(this.options.name, row.digest);
|
|
542
|
+
}
|
|
543
|
+
this.storedFrame(session, row.digest);
|
|
544
|
+
}
|
|
545
|
+
} catch { /* Invalid controls cannot complete graceful shutdown. */ }
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
357
548
|
if (this.session !== session || !this.running) return;
|
|
358
549
|
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
550
|
const bytes = new Uint8Array(data).slice();
|
|
@@ -364,7 +555,7 @@ export class RoomLink {
|
|
|
364
555
|
if (session.processing || this.session !== session || !session.queue.length) return;
|
|
365
556
|
const bytes = session.queue.shift()!; session.queuedBytes -= bytes.length;
|
|
366
557
|
session.processing = true;
|
|
367
|
-
void this.handle(session, bytes).catch(
|
|
558
|
+
void this.handle(session, bytes).catch(error => this.retry(session, error)).finally(() => {
|
|
368
559
|
session.processing = false;
|
|
369
560
|
this.drain(session);
|
|
370
561
|
});
|
|
@@ -404,8 +595,8 @@ export class RoomLink {
|
|
|
404
595
|
return;
|
|
405
596
|
}
|
|
406
597
|
if (message.type === "ready") {
|
|
407
|
-
const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : [])]), roster = session.roster;
|
|
408
|
-
if (ready.ephemeral !== undefined && ready.ephemeral !== true) fail();
|
|
598
|
+
const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : []), ...(Object.hasOwn(message, "codecFeatures") ? ["codecFeatures", "codecSession"] : [])]), roster = session.roster;
|
|
599
|
+
if (ready.ephemeral !== undefined && ready.ephemeral !== true || ready.codecFeatures !== undefined && (ready.codecFeatures !== true || ready.ephemeral !== true || typeof ready.codecSession !== "string" || !DIGEST.test(ready.codecSession))) fail();
|
|
409
600
|
if (!roster || ready.room !== local.current.room || ready.member !== this.options.store.identity.public.fingerprint || ready.epoch !== roster.epoch || ready.digest !== roster.digest) fail("untrusted");
|
|
410
601
|
if (roster.digest !== local.current.digest) {
|
|
411
602
|
if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
|
|
@@ -413,15 +604,20 @@ export class RoomLink {
|
|
|
413
604
|
this.send(session, local.wire); return;
|
|
414
605
|
}
|
|
415
606
|
if (session.presenceRoster !== roster.digest) {
|
|
416
|
-
|
|
607
|
+
if (this.blockedCodec) this.setState(this.state);
|
|
608
|
+
session.presenceRoster = roster.digest; session.features = new Map(); session.codecPeers = new Map(); session.pendingPresence = new Map(); session.pendingPresenceReplies.clear(); session.presenceAsked = false;
|
|
417
609
|
session.requestPresenceReply = true; session.lastPresence = undefined;
|
|
418
610
|
}
|
|
419
611
|
session.ephemeral = ready.ephemeral === true;
|
|
612
|
+
session.codecFeatures = ready.codecFeatures === true;
|
|
613
|
+
session.codecSession = typeof ready.codecSession === "string" ? ready.codecSession : undefined;
|
|
420
614
|
session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
|
|
421
615
|
if (session.deferredFrame) {
|
|
422
616
|
session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
|
|
423
617
|
}
|
|
618
|
+
if (!this.running || this.stopping) return;
|
|
424
619
|
this.setState("online");
|
|
620
|
+
this.armPresenceRefresh(session);
|
|
425
621
|
const context = this.context(session, local.current.digest);
|
|
426
622
|
this.options.application?.members(context);
|
|
427
623
|
this.restoreReceipts(session);
|
|
@@ -432,6 +628,33 @@ export class RoomLink {
|
|
|
432
628
|
void this.publishPresence().catch(() => this.retry(session));
|
|
433
629
|
return;
|
|
434
630
|
}
|
|
631
|
+
if (message.type === "codec_peers") {
|
|
632
|
+
const inventory = exact(message, ["v", "type", "peers", "digest"]);
|
|
633
|
+
if (!Array.isArray(inventory.peers) || inventory.peers.length > 64 || typeof inventory.digest !== "string" || !DIGEST.test(inventory.digest)) fail("untrusted");
|
|
634
|
+
if (inventory.digest !== local.current.digest) return; // A previous-roster observation cannot revoke or grant current support.
|
|
635
|
+
if (!session.codecFeatures || session.phase !== "ready") fail("untrusted");
|
|
636
|
+
const members = roomMembers(local.current), seen = new Set<string>();
|
|
637
|
+
const peers = inventory.peers.map(value => {
|
|
638
|
+
const peer = exact(value, ["member", "session", "supported"]);
|
|
639
|
+
if (typeof peer.member !== "string" || !members.some(member => member.fingerprint === peer.member) || seen.has(peer.member)
|
|
640
|
+
|| peer.member === this.options.store.identity.public.fingerprint || typeof peer.supported !== "boolean"
|
|
641
|
+
|| peer.session !== null && (typeof peer.session !== "string" || !DIGEST.test(peer.session)) || peer.session === null && peer.supported) fail("untrusted");
|
|
642
|
+
seen.add(peer.member);
|
|
643
|
+
return { member: peer.member, session: peer.session as string | null, supported: peer.supported };
|
|
644
|
+
});
|
|
645
|
+
let advertise = false;
|
|
646
|
+
for (const peer of peers) {
|
|
647
|
+
const previous = session.codecPeers?.get(peer.member);
|
|
648
|
+
if (previous?.session === peer.session && previous.supported === peer.supported) continue;
|
|
649
|
+
(session.codecPeers ??= new Map()).set(peer.member, { session: peer.session, supported: peer.supported });
|
|
650
|
+
session.features?.delete(peer.member); session.pendingPresence?.delete(peer.member);
|
|
651
|
+
if (peer.supported && peer.session !== null) advertise = true;
|
|
652
|
+
this.refreshCodecCause(peer.member);
|
|
653
|
+
try { this.options.application?.codecsChanged?.(this.context(session, local.current.digest), { member: peer.member }); } catch { /* Compatibility evidence grants no authority. */ }
|
|
654
|
+
}
|
|
655
|
+
if (advertise) void this.publishCodecPresence(session).catch(error => this.retry(session, error));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
435
658
|
if (message.type === "frame") {
|
|
436
659
|
if (session.phase !== "ready") {
|
|
437
660
|
// X2 may already have one ciphertext in flight while this verified owner
|
|
@@ -483,6 +706,7 @@ export class RoomLink {
|
|
|
483
706
|
// X2 declares these refusal controls nonterminal. Their unsigned control
|
|
484
707
|
// cannot settle any application; its local ACK deadline records uncertainty.
|
|
485
708
|
if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
|
|
709
|
+
this.setState(this.state, "broker_refused");
|
|
486
710
|
fail("unavailable");
|
|
487
711
|
}
|
|
488
712
|
fail();
|
|
@@ -511,9 +735,15 @@ export class RoomLink {
|
|
|
511
735
|
if (!sender) return { status: "trust_changed" };
|
|
512
736
|
let validPresence = false;
|
|
513
737
|
try {
|
|
514
|
-
if (packet(input.plaintext).type === "presence") {
|
|
738
|
+
if (packet(input.plaintext).type === "presence") {
|
|
739
|
+
presence(input.plaintext, sender.exposedPeers);
|
|
740
|
+
const features = packet(input.plaintext).features;
|
|
741
|
+
if (frame.codecFeatures === true ? !session.codecFeatures || features === undefined : features !== undefined) fail();
|
|
742
|
+
validPresence = true;
|
|
743
|
+
}
|
|
515
744
|
else {
|
|
516
745
|
invalidApplication = true;
|
|
746
|
+
if (frame.codecFeatures) fail("untrusted");
|
|
517
747
|
if (!this.options.application) fail("untrusted");
|
|
518
748
|
application = decodeRoomMessage(input.plaintext);
|
|
519
749
|
if (application.type === "chat" && application.envelope.from !== "user" && !sender.exposedPeers.includes(application.envelope.from)) fail("untrusted");
|
|
@@ -562,7 +792,6 @@ export class RoomLink {
|
|
|
562
792
|
this.options.store.settleInbox(this.options.name, acceptedDigest);
|
|
563
793
|
if (result?.status === "accepted") this.capacityChanged(session, digest);
|
|
564
794
|
} else if (result?.status === "accepted") {
|
|
565
|
-
if (result.gap !== "0") try { this.options.application?.gap?.(this.context(session, digest), { sender: frame.sender, count: result.gap }); } catch { /* metadata observation cannot affect admission */ }
|
|
566
795
|
const sender = roomMembers(latest.current).find(member => member.fingerprint === frame.sender);
|
|
567
796
|
if (!sender) fail("untrusted");
|
|
568
797
|
if (application && this.options.application) {
|
|
@@ -570,9 +799,35 @@ export class RoomLink {
|
|
|
570
799
|
} else {
|
|
571
800
|
const peers = presence(result.plaintext, sender.exposedPeers);
|
|
572
801
|
this.current(session, digest);
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
802
|
+
if (frame.codecFeatures === true) {
|
|
803
|
+
const payload = packet(result.plaintext), peer = session.codecPeers?.get(frame.sender);
|
|
804
|
+
if (frame.epoch === latest.current.epoch && peer?.supported && peer.session !== null && payload.codecSession === peer.session) {
|
|
805
|
+
const features = roomCodecFeatures(payload.features), previous = session.features?.get(frame.sender);
|
|
806
|
+
const changed = ROOM_CODEC_FEATURES.filter(feature => previous === undefined || previous.session !== peer.session || previous.features.includes(feature) !== features.includes(feature));
|
|
807
|
+
if (!previous || previous.session !== peer.session || previous.features.length !== features.length || previous.features.some((feature, index) => feature !== features[index])) {
|
|
808
|
+
(session.features ??= new Map()).set(frame.sender, { session: peer.session, features });
|
|
809
|
+
}
|
|
810
|
+
this.refreshCodecCause(frame.sender);
|
|
811
|
+
const pending = session.pendingPresence?.get(frame.sender);
|
|
812
|
+
session.pendingPresence?.delete(frame.sender);
|
|
813
|
+
if (pending?.session === peer.session && pending.epoch === latest.current.epoch) {
|
|
814
|
+
try { this.options.onPresence?.({ member: frame.sender, peers: pending.peers, observedAt: pending.observedAt }); } catch { /* Observation only, with the original local receive time. */ }
|
|
815
|
+
}
|
|
816
|
+
this.pumpFrames(session);
|
|
817
|
+
if (changed.length) try { this.options.application?.codecsChanged?.(this.context(session, digest), { member: frame.sender, features: changed }); } catch { /* Compatibility observations cannot change admission. */ }
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
session.pendingPresenceReplies.delete(frame.sender);
|
|
821
|
+
if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
|
|
822
|
+
const peer = session.codecPeers?.get(frame.sender), advertisement = session.features?.get(frame.sender);
|
|
823
|
+
const knownLegacy = peer !== undefined && peer.session !== null && peer.supported === false;
|
|
824
|
+
if (session.codecFeatures && peers.some(peer => peer.state !== "offline") && !knownLegacy && (!peer?.session || advertisement?.session !== peer.session)) {
|
|
825
|
+
(session.pendingPresence ??= new Map()).set(frame.sender, { session: peer?.session ?? null, epoch: frame.epoch, peers, observedAt: this.observationTime() ?? null });
|
|
826
|
+
} else {
|
|
827
|
+
session.pendingPresence?.delete(frame.sender); // A newer negative or admitted observation supersedes any deferred live state.
|
|
828
|
+
try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
|
|
829
|
+
}
|
|
830
|
+
}
|
|
576
831
|
this.current(session, digest);
|
|
577
832
|
this.options.store.settleInbox(this.options.name, acceptedDigest);
|
|
578
833
|
this.capacityChanged(session, digest);
|
|
@@ -602,6 +857,7 @@ export class RoomLink {
|
|
|
602
857
|
this.pumpFrames(session);
|
|
603
858
|
}
|
|
604
859
|
private restoreReceipts(session: Session): void {
|
|
860
|
+
if (!this.running || this.stopping) return;
|
|
605
861
|
this.current(session);
|
|
606
862
|
this.options.store.expireReceipts(this.options.name);
|
|
607
863
|
for (const entry of this.options.store.outbox(this.options.name)) {
|
|
@@ -632,12 +888,33 @@ export class RoomLink {
|
|
|
632
888
|
this.sendTail = next.then(() => undefined, () => undefined);
|
|
633
889
|
return next;
|
|
634
890
|
}
|
|
891
|
+
/** Host-only admission pause; trust/current predicates remain independent. */
|
|
892
|
+
async quiesceApplications(): Promise<() => void> {
|
|
893
|
+
const token = Symbol(); this.applicationPauses.add(token);
|
|
894
|
+
await this.sendTail;
|
|
895
|
+
let released = false;
|
|
896
|
+
return () => { if (!released) { released = true; this.applicationPauses.delete(token); } };
|
|
897
|
+
}
|
|
635
898
|
async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
|
|
636
899
|
const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
|
|
637
900
|
const session = this.session;
|
|
638
|
-
if (!session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
|
|
901
|
+
if (this.stopping || !this.running || !session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
|
|
902
|
+
if (this.applicationPauses.size) { if (metadata) throw new RoomNotSentError(); fail("state_changed"); }
|
|
903
|
+
const required = roomRequiredCodecFeature(message);
|
|
904
|
+
if (required && !this.codecSupported(recipient, required)) throw this.codecRefusal(recipient, required);
|
|
639
905
|
const digest = this.current(session).current.digest;
|
|
640
906
|
const allowed = () => this.publicationCurrent(session, digest) && current();
|
|
907
|
+
const supported = () => required === undefined || this.codecSupported(recipient, required);
|
|
908
|
+
let codecLost = false;
|
|
909
|
+
const admitted = () => {
|
|
910
|
+
if (!supported()) { codecLost = true; return false; }
|
|
911
|
+
return this.applicationPauses.size === 0 && allowed();
|
|
912
|
+
};
|
|
913
|
+
const assertAdmitted = () => {
|
|
914
|
+
if (admitted()) return;
|
|
915
|
+
if (codecLost && required) throw this.codecRefusal(recipient, required);
|
|
916
|
+
fail("state_changed");
|
|
917
|
+
};
|
|
641
918
|
let queueStarted = false;
|
|
642
919
|
try {
|
|
643
920
|
let presenceWork: Promise<void> | undefined;
|
|
@@ -653,7 +930,7 @@ export class RoomLink {
|
|
|
653
930
|
// Reserve this chat's FIFO slot before yielding to the earlier observation.
|
|
654
931
|
return await this.serialSend(async () => {
|
|
655
932
|
if (presenceWork) await presenceWork;
|
|
656
|
-
|
|
933
|
+
assertAdmitted();
|
|
657
934
|
if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
|
|
658
935
|
|| session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
|
|
659
936
|
if (message.type === "receipt") {
|
|
@@ -667,13 +944,21 @@ export class RoomLink {
|
|
|
667
944
|
const row = this.current(session, digest);
|
|
668
945
|
if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
|
|
669
946
|
let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
|
|
670
|
-
|
|
947
|
+
this.clockRecoveredAfterWrite();
|
|
948
|
+
assertAdmitted();
|
|
671
949
|
let frameDigest: string;
|
|
672
950
|
if (metadata) {
|
|
673
951
|
queueStarted = true;
|
|
674
952
|
let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
|
|
675
|
-
try { record = await this.options.store.queueApplication(this.options.name, wire, metadata,
|
|
953
|
+
try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, admitted); }
|
|
676
954
|
catch (error) {
|
|
955
|
+
if (codecLost && required && error instanceof RoomStoreError && error.code === "state_changed") {
|
|
956
|
+
// The atomic fence refused this attempt. Positively distinguish a
|
|
957
|
+
// never-admitted application from an earlier durable retry intent.
|
|
958
|
+
if (!this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw this.codecRefusal(recipient, required);
|
|
959
|
+
if (this.codecState(recipient, required) !== "unsupported") throw this.codecRefusal(recipient, required);
|
|
960
|
+
throw new RoomNotSentError();
|
|
961
|
+
}
|
|
677
962
|
// An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
|
|
678
963
|
if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
|
|
679
964
|
&& !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
|
|
@@ -682,7 +967,7 @@ export class RoomLink {
|
|
|
682
967
|
frameDigest = record.frameDigest ?? record.digest;
|
|
683
968
|
} else if (message.type === "receipt") {
|
|
684
969
|
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 },
|
|
970
|
+
const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, admitted);
|
|
686
971
|
if (!allowed()) fail("state_changed");
|
|
687
972
|
if (receipt.state === "accepted") return receipt.digest; // Existing broker-stored proof needs no pending wire slot.
|
|
688
973
|
frameDigest = receipt.digest; wire = receipt.wire;
|
|
@@ -692,7 +977,7 @@ export class RoomLink {
|
|
|
692
977
|
this.queueFrame(session, wire, frameDigest, false, allowed, false,
|
|
693
978
|
metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
|
|
694
979
|
: message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
|
|
695
|
-
undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined);
|
|
980
|
+
undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined, false, required ? supported : undefined, required ? () => this.codecState(recipient, required) === "pending" : undefined, required ? { member: recipient, feature: required } : undefined);
|
|
696
981
|
} catch (error) {
|
|
697
982
|
// The durable outbox owns retry after local backpressure; this is no uncertainty.
|
|
698
983
|
if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
|
|
@@ -700,13 +985,53 @@ export class RoomLink {
|
|
|
700
985
|
}
|
|
701
986
|
return frameDigest;
|
|
702
987
|
}); } catch (error) {
|
|
988
|
+
if (error instanceof RoomCodecUnsupportedError || error instanceof RoomCodecPendingError) throw error;
|
|
703
989
|
if (metadata && !queueStarted) throw new RoomNotSentError();
|
|
704
990
|
throw error;
|
|
705
991
|
}
|
|
706
992
|
}
|
|
993
|
+
/** A newly ready member may have missed our first advertisement. Repair it
|
|
994
|
+
* independently of legacy request/reply and snapshot dedupe, without a timer. */
|
|
995
|
+
private publishCodecPresence(session: Session): Promise<void> {
|
|
996
|
+
if (session.codecPublishing) { session.codecDirty = true; return session.codecPublishing; }
|
|
997
|
+
const work = (async () => {
|
|
998
|
+
do {
|
|
999
|
+
session.codecDirty = false;
|
|
1000
|
+
await this.serialSend(async () => {
|
|
1001
|
+
if (!this.running || this.stopping || this.session !== session || !session.codecFeatures || !session.codecSession || session.phase !== "ready") return;
|
|
1002
|
+
const row = this.current(session), identity = this.options.store.identity;
|
|
1003
|
+
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
1004
|
+
const own = roomMembers(row.current).find(member => member.fingerprint === identity.public.fingerprint);
|
|
1005
|
+
if (!own) fail("untrusted");
|
|
1006
|
+
const peers = this.currentPresence(own.exposedPeers), recipients = roomMembers(row.current).map(member => member.fingerprint).filter(member => member !== identity.public.fingerprint).sort();
|
|
1007
|
+
if (!recipients.length) return;
|
|
1008
|
+
const wire = await sealFrame({ epoch: row.current, identity, recipients,
|
|
1009
|
+
plaintext: canonicalBytes({ v: 1, type: "presence", peers, features: ROOM_CODEC_FEATURES, codecSession: session.codecSession }),
|
|
1010
|
+
ephemeral: true, codecFeatures: true, sequence: this.options.store.sequence });
|
|
1011
|
+
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
1012
|
+
const digest = await this.frameDigest(wire);
|
|
1013
|
+
this.queueFrame(session, wire, digest, false,
|
|
1014
|
+
() => this.publicationCurrent(session, row.current.digest) && peers.every(({ id }) => this.currentPresence(own.exposedPeers).some(value => value.id === id)),
|
|
1015
|
+
true, undefined, undefined, undefined, true);
|
|
1016
|
+
});
|
|
1017
|
+
} while (session.codecDirty && this.session === session && this.running && !this.stopping);
|
|
1018
|
+
})();
|
|
1019
|
+
session.codecPublishing = work;
|
|
1020
|
+
void work.finally(() => {
|
|
1021
|
+
if (session.codecPublishing !== work) return;
|
|
1022
|
+
session.codecPublishing = undefined;
|
|
1023
|
+
const again = session.codecDirty; session.codecDirty = false;
|
|
1024
|
+
// A peer event can arrive after the loop resolved but before this cleanup.
|
|
1025
|
+
// Consume that real event once, after releasing the old publication slot.
|
|
1026
|
+
if (again && this.session === session && this.running && !this.stopping && session.phase === "ready") {
|
|
1027
|
+
void this.publishCodecPresence(session).catch(error => this.retry(session, error));
|
|
1028
|
+
}
|
|
1029
|
+
}).catch(() => undefined);
|
|
1030
|
+
return work;
|
|
1031
|
+
}
|
|
707
1032
|
async publishPresence(): Promise<void> {
|
|
708
1033
|
const session = this.session;
|
|
709
|
-
if (!session || session.phase !== "ready") return;
|
|
1034
|
+
if (!this.running || this.stopping || !session || session.phase !== "ready") return;
|
|
710
1035
|
if (this.publishing) { this.publishDirty = true; return; }
|
|
711
1036
|
// Keep the already scheduled coalesced snapshot. Cancelling it on every
|
|
712
1037
|
// state flip turns an awaited burst back into one durable seal per call.
|
|
@@ -714,9 +1039,9 @@ export class RoomLink {
|
|
|
714
1039
|
if (this.republish) clearTimeout(this.republish);
|
|
715
1040
|
this.republish = undefined; this.publishDirty = false;
|
|
716
1041
|
const forceRequest = this.forcePresenceRequest;
|
|
717
|
-
const response = this.replyPresence
|
|
1042
|
+
const response = this.replyPresence;
|
|
718
1043
|
if (response) this.replyPresence = false;
|
|
719
|
-
this.forcePresenceRequest =
|
|
1044
|
+
this.forcePresenceRequest = response && forceRequest;
|
|
720
1045
|
let sent = false;
|
|
721
1046
|
this.publishing = true;
|
|
722
1047
|
try {
|
|
@@ -732,7 +1057,20 @@ export class RoomLink {
|
|
|
732
1057
|
const requestReply = !response && session.requestPresenceReply;
|
|
733
1058
|
const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
|
|
734
1059
|
if (!response && !forceRequest && session.lastPresence === presenceKey) return;
|
|
1060
|
+
// Positive codec evidence precedes the peer-state observation on this
|
|
1061
|
+
// FIFO lane. Observed idle never races its own pending advertisement.
|
|
1062
|
+
if (session.codecFeatures && session.codecSession) {
|
|
1063
|
+
const featureWire = await sealFrame({ epoch: row.current, identity, recipients,
|
|
1064
|
+
plaintext: canonicalBytes({ v: 1, type: "presence", peers, features: ROOM_CODEC_FEATURES, codecSession: session.codecSession }),
|
|
1065
|
+
ephemeral: true, codecFeatures: true, sequence: this.options.store.sequence });
|
|
1066
|
+
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
1067
|
+
const featureDigest = await this.frameDigest(featureWire);
|
|
1068
|
+
this.queueFrame(session, featureWire, featureDigest, false,
|
|
1069
|
+
() => this.publicationCurrent(session, row.current.digest) && peers.every(({ id }) => this.currentPresence(own.exposedPeers).some(value => value.id === id)),
|
|
1070
|
+
true, undefined, undefined, undefined, true);
|
|
1071
|
+
}
|
|
735
1072
|
const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers, ...(requestReply ? { requestReply: true } : {}) }), ...(session.ephemeral ? { ephemeral: true as const } : {}), sequence: this.options.store.sequence });
|
|
1073
|
+
this.clockRecoveredAfterWrite();
|
|
736
1074
|
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
737
1075
|
// Machine-local exposure can change during reserve/encryption/signing even
|
|
738
1076
|
// when the signed roster is unchanged. Never emit an id it now withholds.
|
|
@@ -750,7 +1088,7 @@ export class RoomLink {
|
|
|
750
1088
|
session.lastPresence = presenceKey; sent = true;
|
|
751
1089
|
});
|
|
752
1090
|
} catch (error) {
|
|
753
|
-
this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.
|
|
1091
|
+
this.retry(session, error); // Existing bounded reconnect, never a self-polling refresh retry.
|
|
754
1092
|
throw error;
|
|
755
1093
|
} finally {
|
|
756
1094
|
this.publishing = false;
|