@staix/agent-hub 0.30.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 +52 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +11 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +178 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +23 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +355 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +24 -5
- package/docs/security.md +178 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-0.32.0.md +149 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +78 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/room.ts +20 -10
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +511 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1576 -139
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +338 -36
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +475 -35
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +447 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +142 -9
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/rooms/link.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "..
|
|
|
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
|
-
import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, type RoomOutgoingInput } from "./store.ts";
|
|
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 {
|
|
@@ -28,6 +31,7 @@ export interface RoomLinkApplication {
|
|
|
28
31
|
stored(context: RoomLinkContext, input: { digest: string; seq: string }): void;
|
|
29
32
|
/** Fixed transport metadata only; never application completion or authority. */
|
|
30
33
|
transportAck?(context: RoomLinkContext, input: { digest: string }): void;
|
|
34
|
+
receiptTtlMs?(context: RoomLinkContext): number;
|
|
31
35
|
capacityChanged?(context: RoomLinkContext): void;
|
|
32
36
|
gap?(context: RoomLinkContext, input: { sender: string; count: string }): void;
|
|
33
37
|
}
|
|
@@ -40,12 +44,34 @@ export class RoomLinkError extends Error {
|
|
|
40
44
|
super(`Room link: ${code}`); this.name = "RoomLinkError";
|
|
41
45
|
}
|
|
42
46
|
}
|
|
47
|
+
/** Only before durable application enqueue: the Bus may retain its original queue item. */
|
|
48
|
+
export class RoomNotSentError extends Error {
|
|
49
|
+
readonly code = "not_sent";
|
|
50
|
+
constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
|
|
51
|
+
}
|
|
52
|
+
const RECEIPT_TTL_MS = 24 * 3_600_000;
|
|
43
53
|
const LIMITS = Object.freeze({ packets: 8, queuedBytes: 2_097_152, packetBytes: 1_048_576, backpressure: 1_048_576, handshakeMs: 30_000, reconnectMaxMs: 300_000 });
|
|
44
54
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
45
55
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
46
56
|
const DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
47
57
|
const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
|
|
58
|
+
interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
|
|
48
59
|
interface Session {
|
|
60
|
+
closingHead?: PendingFrame;
|
|
61
|
+
refresh?: ReturnType<typeof setTimeout>;
|
|
62
|
+
refreshedAt?: number;
|
|
63
|
+
processingFrame?: string;
|
|
64
|
+
acknowledgedFrame?: string;
|
|
65
|
+
pendingPresenceReplies: Set<string>;
|
|
66
|
+
presenceAsked: boolean;
|
|
67
|
+
presenceRoster?: string;
|
|
68
|
+
outbound: PendingFrame[];
|
|
69
|
+
outboundBytes: number;
|
|
70
|
+
frameSent: boolean;
|
|
71
|
+
retryAttempt: number;
|
|
72
|
+
frameRetry?: ReturnType<typeof setTimeout>;
|
|
73
|
+
lastPresence?: string;
|
|
74
|
+
ephemeral?: boolean;
|
|
49
75
|
ws: WebSocket;
|
|
50
76
|
generation: number;
|
|
51
77
|
room: string;
|
|
@@ -95,6 +121,11 @@ function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePe
|
|
|
95
121
|
/** One bounded, generation-fenced outbound connection. No callback receives body text or task data. */
|
|
96
122
|
export class RoomLink {
|
|
97
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>();
|
|
98
129
|
private generation = 0;
|
|
99
130
|
private session?: Session;
|
|
100
131
|
private reconnect?: ReturnType<typeof setTimeout>;
|
|
@@ -109,10 +140,12 @@ export class RoomLink {
|
|
|
109
140
|
private forcePresenceRequest = false;
|
|
110
141
|
private republish?: ReturnType<typeof setTimeout>;
|
|
111
142
|
private sendTail: Promise<void> = Promise.resolve();
|
|
112
|
-
constructor(private readonly options: RoomLinkOptions) {}
|
|
143
|
+
constructor(private readonly options: RoomLinkOptions) { this.refreshMs = roomRefreshPeriod(options.refreshMs); }
|
|
113
144
|
|
|
114
145
|
/** Advisory transport state without copying or decoding a room epoch for a status poll. */
|
|
115
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; }
|
|
116
149
|
status(): { state: RoomLinkState; members: number } {
|
|
117
150
|
try {
|
|
118
151
|
const row = this.options.store.get(this.options.name);
|
|
@@ -120,39 +153,105 @@ export class RoomLink {
|
|
|
120
153
|
} catch { return { state: "offline", members: 0 }; }
|
|
121
154
|
}
|
|
122
155
|
async start(): Promise<void> {
|
|
123
|
-
if (this.running) return;
|
|
156
|
+
if (this.running || this.stopping) return;
|
|
157
|
+
this.stopWork = undefined;
|
|
124
158
|
this.running = true;
|
|
125
159
|
const generation = ++this.generation;
|
|
126
160
|
try { await assertRoomCryptoReady(); } catch { this.running = false; throw new RoomLinkError("unavailable"); }
|
|
127
161
|
if (!this.running || generation !== this.generation) return;
|
|
128
162
|
this.connect();
|
|
129
163
|
}
|
|
130
|
-
stop(): void {
|
|
131
|
-
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];
|
|
132
169
|
if (this.reconnect) clearTimeout(this.reconnect);
|
|
133
170
|
this.reconnect = undefined;
|
|
134
|
-
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
|
+
});
|
|
135
215
|
}
|
|
136
216
|
refresh(): void {
|
|
217
|
+
if (this.stopping) return;
|
|
137
218
|
if (this.reconnect) clearTimeout(this.reconnect);
|
|
138
219
|
this.reconnect = undefined;
|
|
139
220
|
this.disconnect();
|
|
140
221
|
if (this.running) this.connect();
|
|
141
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
|
+
}
|
|
142
241
|
private setState(state: RoomLinkState): void {
|
|
143
242
|
if (this.state === state) return;
|
|
144
243
|
this.state = state;
|
|
145
244
|
try { this.options.onState?.(state); } catch { /* observation never changes transport authority */ }
|
|
146
245
|
}
|
|
147
|
-
private current(session: Session, digest?: string): RoomState {
|
|
148
|
-
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");
|
|
149
248
|
let row: RoomState | undefined;
|
|
150
249
|
try { row = this.options.store.get(this.options.name); } catch { fail("unavailable"); }
|
|
151
250
|
if (!row || row.left || row.current.room !== session.room || row.broker !== session.broker || (digest !== undefined && row.current.digest !== digest)) fail("state_changed");
|
|
152
251
|
return row;
|
|
153
252
|
}
|
|
154
253
|
private isCurrent(session: Session, digest: string): boolean {
|
|
155
|
-
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; }
|
|
156
255
|
}
|
|
157
256
|
private publicationCurrent(session: Session, digest: string): boolean {
|
|
158
257
|
return session.phase === "ready" && session.roster?.digest === digest && this.isCurrent(session, digest);
|
|
@@ -165,6 +264,7 @@ export class RoomLink {
|
|
|
165
264
|
if (!this.publicationCurrent(session, row.current.digest) || !this.options.store.ordinaryInboxAvailable(this.options.name, bytes)) return;
|
|
166
265
|
// Intent survives coalescing, ordinary responses and publication failure.
|
|
167
266
|
// Only an actually sent request with current capacity may clear the numeric flag.
|
|
267
|
+
if (!this.forcePresenceRequest) { session.pendingPresenceReplies.clear(); session.presenceAsked = false; }
|
|
168
268
|
session.requestPresenceReply = true; this.forcePresenceRequest = true;
|
|
169
269
|
try { await this.publishPresence(); }
|
|
170
270
|
catch (error) { this.retry(session); throw error; }
|
|
@@ -194,7 +294,7 @@ export class RoomLink {
|
|
|
194
294
|
}
|
|
195
295
|
let peers: RoomPresencePeer[] | undefined;
|
|
196
296
|
try { peers = presence(entry.plaintext, member.exposedPeers); } catch { /* a rotation may have removed an exposed id since the durable receive */ }
|
|
197
|
-
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 */ } }
|
|
198
298
|
}
|
|
199
299
|
// Presence is ephemeral: stale exposure/member metadata is discarded, never
|
|
200
300
|
// replayed after a rotation or left at the head of recovery forever.
|
|
@@ -204,6 +304,7 @@ export class RoomLink {
|
|
|
204
304
|
}
|
|
205
305
|
}
|
|
206
306
|
private async deliverApplication(session: Session, digest: string, input: Parameters<RoomLinkApplication["receive"]>[1]): Promise<void> {
|
|
307
|
+
if (!this.running || this.stopping) fail("state_changed");
|
|
207
308
|
try { await this.options.application?.receive(this.context(session, digest), input); }
|
|
208
309
|
catch (error) { if (!(error instanceof RoomApplicationDeferredError)) throw error; }
|
|
209
310
|
this.current(session, digest);
|
|
@@ -233,12 +334,16 @@ export class RoomLink {
|
|
|
233
334
|
this.republish = undefined;
|
|
234
335
|
if (session) {
|
|
235
336
|
clearTimeout(session.handshake);
|
|
337
|
+
if (session.refresh) clearTimeout(session.refresh);
|
|
338
|
+
if (session.frameRetry) clearTimeout(session.frameRetry);
|
|
339
|
+
session.outbound.length = 0; session.outboundBytes = 0; session.pendingPresenceReplies.clear();
|
|
236
340
|
session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
|
|
237
341
|
try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
|
|
238
342
|
}
|
|
239
343
|
this.setState("offline");
|
|
240
344
|
}
|
|
241
345
|
private retry(session?: Session): void {
|
|
346
|
+
if (this.stopping) return;
|
|
242
347
|
if (session && this.session !== session) return;
|
|
243
348
|
this.disconnect(session);
|
|
244
349
|
if (!this.running || this.reconnect) return;
|
|
@@ -259,10 +364,11 @@ export class RoomLink {
|
|
|
259
364
|
} catch { this.setState("offline"); return; }
|
|
260
365
|
url.pathname = `/v1/rooms/${row.current.room}`;
|
|
261
366
|
let ws: WebSocket;
|
|
262
|
-
|
|
367
|
+
// ponytail: negotiate only ephemeral here; replace this opt-in with #1204 T4 codec capabilities.
|
|
368
|
+
try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1" } }); } catch { this.retry(); return; }
|
|
263
369
|
ws.binaryType = "arraybuffer";
|
|
264
370
|
const generation = ++this.generation;
|
|
265
|
-
const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
|
|
371
|
+
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,
|
|
266
372
|
handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
|
|
267
373
|
session.handshake.unref?.();
|
|
268
374
|
this.session = session;
|
|
@@ -272,11 +378,84 @@ export class RoomLink {
|
|
|
272
378
|
ws.addEventListener("error", () => this.retry(session));
|
|
273
379
|
}
|
|
274
380
|
private send(session: Session, bytes: Uint8Array): void {
|
|
275
|
-
this.current(session);
|
|
381
|
+
this.current(session, undefined, this.stopping);
|
|
276
382
|
if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
|
|
277
383
|
session.ws.send(bytes);
|
|
278
384
|
}
|
|
385
|
+
private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
|
|
386
|
+
this.current(session, undefined, this.stopping);
|
|
387
|
+
// Only an unsent observation may be replaced. Never reorder an attempted frame.
|
|
388
|
+
if (presence) {
|
|
389
|
+
const at = session.outbound.findIndex((frame, index) => frame !== session.closingHead && frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
|
|
390
|
+
if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
|
|
391
|
+
}
|
|
392
|
+
if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
|
|
393
|
+
session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck }); session.outboundBytes += wire.byteLength;
|
|
394
|
+
this.pumpFrames(session);
|
|
395
|
+
}
|
|
396
|
+
private pumpFrames(session: Session): void {
|
|
397
|
+
if (session.frameSent || session.frameRetry || !session.outbound.length) return;
|
|
398
|
+
this.current(session, undefined, this.stopping);
|
|
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;
|
|
402
|
+
if (frame.retained && !frame.retained()) {
|
|
403
|
+
// Disposed/expired intent cannot survive in the transport's RAM queue.
|
|
404
|
+
session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
|
|
405
|
+
this.pumpFrames(session); return;
|
|
406
|
+
}
|
|
407
|
+
if (!frame.current()) {
|
|
408
|
+
if (!frame.presence) fail("state_changed");
|
|
409
|
+
session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.lastPresence = undefined;
|
|
410
|
+
this.pumpFrames(session);
|
|
411
|
+
void this.publishPresence().catch(() => this.retry(session));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// A foreground receipt follows the chat ACK on the socket. Otherwise broker
|
|
415
|
+
// verification/storage of the receipt can pin the chat's one-packet slot.
|
|
416
|
+
if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
|
|
417
|
+
this.send(session, frame.wire); session.frameSent = true;
|
|
418
|
+
const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
|
|
419
|
+
}
|
|
420
|
+
private storedFrame(session: Session, digest: string): void {
|
|
421
|
+
if (session.outbound[0]?.digest !== digest) return;
|
|
422
|
+
const frame = session.outbound.shift()!; session.outboundBytes -= frame.wire.byteLength;
|
|
423
|
+
if (session.closingHead === frame) session.closingHead = undefined;
|
|
424
|
+
session.frameSent = false; session.retryAttempt = 0;
|
|
425
|
+
if (session.frameRetry) clearTimeout(session.frameRetry);
|
|
426
|
+
session.frameRetry = undefined;
|
|
427
|
+
if (this.stoppedFrame?.digest === digest) this.stoppedFrame.resolve();
|
|
428
|
+
this.pumpFrames(session);
|
|
429
|
+
}
|
|
430
|
+
private backoffFrame(session: Session, digest: string): void {
|
|
431
|
+
if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
|
|
432
|
+
session.frameSent = false;
|
|
433
|
+
const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
|
|
434
|
+
session.frameRetry = setTimeout(() => {
|
|
435
|
+
session.frameRetry = undefined;
|
|
436
|
+
if (this.session !== session || !this.running || session.phase !== "ready") return;
|
|
437
|
+
try { this.pumpFrames(session); } catch { this.retry(session); }
|
|
438
|
+
}, delay);
|
|
439
|
+
session.frameRetry.unref?.();
|
|
440
|
+
this.pumpFrames(session);
|
|
441
|
+
}
|
|
279
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
|
+
}
|
|
280
459
|
if (this.session !== session || !this.running) return;
|
|
281
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; }
|
|
282
461
|
const bytes = new Uint8Array(data).slice();
|
|
@@ -327,20 +506,29 @@ export class RoomLink {
|
|
|
327
506
|
return;
|
|
328
507
|
}
|
|
329
508
|
if (message.type === "ready") {
|
|
330
|
-
const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest"]), roster = session.roster;
|
|
509
|
+
const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : [])]), roster = session.roster;
|
|
510
|
+
if (ready.ephemeral !== undefined && ready.ephemeral !== true) fail();
|
|
331
511
|
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");
|
|
332
512
|
if (roster.digest !== local.current.digest) {
|
|
333
513
|
if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
|
|
334
514
|
session.ownerUpgrading = true;
|
|
335
515
|
this.send(session, local.wire); return;
|
|
336
516
|
}
|
|
517
|
+
if (session.presenceRoster !== roster.digest) {
|
|
518
|
+
session.presenceRoster = roster.digest; session.pendingPresenceReplies.clear(); session.presenceAsked = false;
|
|
519
|
+
session.requestPresenceReply = true; session.lastPresence = undefined;
|
|
520
|
+
}
|
|
521
|
+
session.ephemeral = ready.ephemeral === true;
|
|
337
522
|
session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
|
|
338
523
|
if (session.deferredFrame) {
|
|
339
524
|
session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
|
|
340
525
|
}
|
|
526
|
+
if (!this.running || this.stopping) return;
|
|
527
|
+
this.armPresenceRefresh(session);
|
|
341
528
|
this.setState("online");
|
|
342
529
|
const context = this.context(session, local.current.digest);
|
|
343
530
|
this.options.application?.members(context);
|
|
531
|
+
this.restoreReceipts(session);
|
|
344
532
|
await this.retryPendingApplications();
|
|
345
533
|
this.current(session, local.current.digest);
|
|
346
534
|
await this.options.application?.online(context);
|
|
@@ -365,7 +553,14 @@ export class RoomLink {
|
|
|
365
553
|
if (message.type === "stored") {
|
|
366
554
|
const row = exact(message, ["v", "type", "digest", "seq", "duplicate"]);
|
|
367
555
|
if (typeof row.digest !== "string" || !DIGEST.test(row.digest) || typeof row.seq !== "string" || !/^[1-9][0-9]{0,19}$/.test(row.seq) || typeof row.duplicate !== "boolean") fail();
|
|
556
|
+
const receipt = this.options.store.receiptSendable(this.options.name, row.digest);
|
|
557
|
+
if (receipt) {
|
|
558
|
+
this.options.store.markStored(this.options.name, row.digest, row.seq);
|
|
559
|
+
this.options.store.retireStoredReceipt(this.options.name, row.digest);
|
|
560
|
+
}
|
|
561
|
+
this.storedFrame(session, row.digest);
|
|
368
562
|
this.options.application?.stored(this.context(session, local.current.digest), { digest: row.digest, seq: row.seq });
|
|
563
|
+
this.restoreReceipts(session);
|
|
369
564
|
return; // Broker storage is not a signed recipient application-completion receipt.
|
|
370
565
|
}
|
|
371
566
|
if (message.type === "queue_ack") {
|
|
@@ -381,10 +576,17 @@ export class RoomLink {
|
|
|
381
576
|
return;
|
|
382
577
|
}
|
|
383
578
|
if (message.type === "error") {
|
|
384
|
-
const error = exact(message, ["v", "type", "code"]);
|
|
579
|
+
const error = exact(message, Object.hasOwn(message, "digest") ? ["v", "type", "code", "digest"] : ["v", "type", "code"]);
|
|
580
|
+
if (error.digest !== undefined && (error.code !== "queue_full" || typeof error.digest !== "string" || !DIGEST.test(error.digest))) fail();
|
|
581
|
+
if (error.code === "queue_full") {
|
|
582
|
+
// Older brokers omit correlation; one frame in flight makes its head unambiguous.
|
|
583
|
+
const digest = typeof error.digest === "string" ? error.digest : session.outbound[0]?.digest;
|
|
584
|
+
if (digest) this.backoffFrame(session, digest);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
385
587
|
// X2 declares these refusal controls nonterminal. Their unsigned control
|
|
386
588
|
// cannot settle any application; its local ACK deadline records uncertainty.
|
|
387
|
-
if (error.code === "
|
|
589
|
+
if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
|
|
388
590
|
fail("unavailable");
|
|
389
591
|
}
|
|
390
592
|
fail();
|
|
@@ -449,6 +651,7 @@ export class RoomLink {
|
|
|
449
651
|
else throw error;
|
|
450
652
|
}
|
|
451
653
|
const acceptedDigest = await this.frameDigest(bytes);
|
|
654
|
+
session.processingFrame = acceptedDigest;
|
|
452
655
|
const latest = this.frameCurrent(session, frame);
|
|
453
656
|
if (latest.current.digest !== digest) refused = "trust_changed";
|
|
454
657
|
if (result?.status === "stale") {
|
|
@@ -471,6 +674,8 @@ export class RoomLink {
|
|
|
471
674
|
} else {
|
|
472
675
|
const peers = presence(result.plaintext, sender.exposedPeers);
|
|
473
676
|
this.current(session, digest);
|
|
677
|
+
session.pendingPresenceReplies.delete(frame.sender);
|
|
678
|
+
if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
|
|
474
679
|
try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
|
|
475
680
|
this.current(session, digest);
|
|
476
681
|
this.options.store.settleInbox(this.options.name, acceptedDigest);
|
|
@@ -486,9 +691,33 @@ export class RoomLink {
|
|
|
486
691
|
this.frameCurrent(session, frame);
|
|
487
692
|
// This ACK only retires ciphertext from the broker queue. A durable refusal
|
|
488
693
|
// or stale drop is never a signed recipient application-completion receipt.
|
|
489
|
-
this.
|
|
694
|
+
this.retireFrame(session, frame, acceptedDigest);
|
|
695
|
+
session.processingFrame = undefined; session.acknowledgedFrame = undefined;
|
|
490
696
|
await this.refreshPresenceIfCapacityFree().catch(() => undefined);
|
|
491
697
|
}
|
|
698
|
+
private retireFrame(session: Session, frame: FrameWire, digest: string): void {
|
|
699
|
+
this.frameCurrent(session, frame);
|
|
700
|
+
// Final admission commits its receipt; deferred admission retains the durable inbox.
|
|
701
|
+
// Waiting for broker storage or deferred admission would pin capacity one indefinitely.
|
|
702
|
+
if (this.options.store.applicationReceipt(this.options.name, digest)
|
|
703
|
+
&& !this.options.store.receiptStored(this.options.name, digest)) fail("unavailable");
|
|
704
|
+
this.send(session, encodeBrokerControl({ v: 1, type: "received", digest }));
|
|
705
|
+
session.acknowledgedFrame = digest;
|
|
706
|
+
this.pumpFrames(session);
|
|
707
|
+
}
|
|
708
|
+
private restoreReceipts(session: Session): void {
|
|
709
|
+
if (!this.running || this.stopping) return;
|
|
710
|
+
this.current(session);
|
|
711
|
+
this.options.store.expireReceipts(this.options.name);
|
|
712
|
+
for (const entry of this.options.store.outbox(this.options.name)) {
|
|
713
|
+
if (entry.kind !== "receipt" || session.outbound.some(frame => frame.digest === entry.digest)) continue;
|
|
714
|
+
if (session.outbound.length >= LIMITS.packets || session.outboundBytes + entry.wire.byteLength > LIMITS.queuedBytes) break;
|
|
715
|
+
const roster = this.current(session).current.digest;
|
|
716
|
+
this.queueFrame(session, entry.wire, entry.digest, false, () => this.publicationCurrent(session, roster), false,
|
|
717
|
+
() => this.options.store.receiptSendable(this.options.name, entry.digest), undefined,
|
|
718
|
+
entry.receiptFor === session.processingFrame ? entry.receiptFor : undefined);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
492
721
|
private async frameDigest(bytes: Uint8Array): Promise<string> {
|
|
493
722
|
return encodeBase64url(await sha256(bytes));
|
|
494
723
|
}
|
|
@@ -508,34 +737,100 @@ export class RoomLink {
|
|
|
508
737
|
this.sendTail = next.then(() => undefined, () => undefined);
|
|
509
738
|
return next;
|
|
510
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
|
+
}
|
|
511
747
|
async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
|
|
512
748
|
const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
|
|
513
749
|
const session = this.session;
|
|
514
|
-
if (!session || session.phase !== "ready") 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"); }
|
|
515
752
|
const digest = this.current(session).current.digest;
|
|
516
753
|
const allowed = () => this.publicationCurrent(session, digest) && current();
|
|
517
|
-
|
|
518
|
-
|
|
754
|
+
const admitted = () => this.applicationPauses.size === 0 && allowed();
|
|
755
|
+
let queueStarted = false;
|
|
756
|
+
try {
|
|
757
|
+
let presenceWork: Promise<void> | undefined;
|
|
758
|
+
// A chat is the transport barrier for earlier observations. Receipts keep
|
|
759
|
+
// their durable-commit/ACK path and do not wait behind a new observation.
|
|
760
|
+
if (message.type === "chat" && this.republish) {
|
|
761
|
+
clearTimeout(this.republish); this.republish = undefined;
|
|
762
|
+
presenceWork = this.publishPresence();
|
|
763
|
+
// It may fail before reaching the serial lane. Observe rejection now;
|
|
764
|
+
// the reserved chat slot still awaits the original error below.
|
|
765
|
+
void presenceWork.catch(() => undefined);
|
|
766
|
+
}
|
|
767
|
+
// Reserve this chat's FIFO slot before yielding to the earlier observation.
|
|
768
|
+
return await this.serialSend(async () => {
|
|
769
|
+
if (presenceWork) await presenceWork;
|
|
770
|
+
if (!admitted()) fail("state_changed");
|
|
771
|
+
if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
|
|
772
|
+
|| session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
|
|
773
|
+
if (message.type === "receipt") {
|
|
774
|
+
const pending = this.options.store.receiptStored(this.options.name, message.frame);
|
|
775
|
+
if (pending?.state === "dispatching") {
|
|
776
|
+
if (!decodeFrame(pending.wire).recipients.includes(recipient)) fail("untrusted");
|
|
777
|
+
this.restoreReceipts(session);
|
|
778
|
+
return pending.digest;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
519
781
|
const row = this.current(session, digest);
|
|
520
782
|
if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
|
|
521
|
-
|
|
522
|
-
if (!
|
|
523
|
-
|
|
524
|
-
|
|
783
|
+
let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
|
|
784
|
+
if (!admitted()) fail("state_changed");
|
|
785
|
+
let frameDigest: string;
|
|
786
|
+
if (metadata) {
|
|
787
|
+
queueStarted = true;
|
|
788
|
+
let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
|
|
789
|
+
try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, admitted); }
|
|
790
|
+
catch (error) {
|
|
791
|
+
// An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
|
|
792
|
+
if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
|
|
793
|
+
&& !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
frameDigest = record.frameDigest ?? record.digest;
|
|
797
|
+
} else if (message.type === "receipt") {
|
|
798
|
+
const ttlMs = this.options.application?.receiptTtlMs?.(this.context(session, digest)) ?? RECEIPT_TTL_MS;
|
|
799
|
+
const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, admitted);
|
|
800
|
+
if (!allowed()) fail("state_changed");
|
|
801
|
+
if (receipt.state === "accepted") return receipt.digest; // Existing broker-stored proof needs no pending wire slot.
|
|
802
|
+
frameDigest = receipt.digest; wire = receipt.wire;
|
|
803
|
+
} else frameDigest = await this.frameDigest(wire);
|
|
525
804
|
if (!allowed()) fail("state_changed");
|
|
526
|
-
|
|
805
|
+
try {
|
|
806
|
+
this.queueFrame(session, wire, frameDigest, false, allowed, false,
|
|
807
|
+
metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
|
|
808
|
+
: message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
|
|
809
|
+
undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
// The durable outbox owns retry after local backpressure; this is no uncertainty.
|
|
812
|
+
if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
|
|
813
|
+
if (session.outbound[0]?.digest === frameDigest) this.backoffFrame(session, frameDigest);
|
|
814
|
+
}
|
|
527
815
|
return frameDigest;
|
|
528
|
-
});
|
|
816
|
+
}); } catch (error) {
|
|
817
|
+
if (metadata && !queueStarted) throw new RoomNotSentError();
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
529
820
|
}
|
|
530
821
|
async publishPresence(): Promise<void> {
|
|
531
822
|
const session = this.session;
|
|
532
|
-
if (!session || session.phase !== "ready") return;
|
|
823
|
+
if (!this.running || this.stopping || !session || session.phase !== "ready") return;
|
|
533
824
|
if (this.publishing) { this.publishDirty = true; return; }
|
|
825
|
+
// Keep the already scheduled coalesced snapshot. Cancelling it on every
|
|
826
|
+
// state flip turns an awaited burst back into one durable seal per call.
|
|
827
|
+
if (this.republish && !this.replyPresence && !this.forcePresenceRequest) { this.publishDirty = true; return; }
|
|
534
828
|
if (this.republish) clearTimeout(this.republish);
|
|
535
829
|
this.republish = undefined; this.publishDirty = false;
|
|
536
|
-
const
|
|
830
|
+
const forceRequest = this.forcePresenceRequest;
|
|
831
|
+
const response = this.replyPresence;
|
|
537
832
|
if (response) this.replyPresence = false;
|
|
538
|
-
this.forcePresenceRequest =
|
|
833
|
+
this.forcePresenceRequest = response && forceRequest;
|
|
539
834
|
let sent = false;
|
|
540
835
|
this.publishing = true;
|
|
541
836
|
try {
|
|
@@ -549,17 +844,24 @@ export class RoomLink {
|
|
|
549
844
|
await this.serialSend(async () => {
|
|
550
845
|
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
551
846
|
const requestReply = !response && session.requestPresenceReply;
|
|
552
|
-
const
|
|
847
|
+
const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
|
|
848
|
+
if (!response && !forceRequest && session.lastPresence === presenceKey) return;
|
|
849
|
+
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 });
|
|
553
850
|
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
554
851
|
// Machine-local exposure can change during reserve/encryption/signing even
|
|
555
852
|
// when the signed roster is unchanged. Never emit an id it now withholds.
|
|
853
|
+
const frameDigest = await this.frameDigest(wire);
|
|
854
|
+
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
556
855
|
const allowed = new Set(this.currentPresence(own.exposedPeers).map(peer => peer.id));
|
|
557
856
|
if (peers.some(peer => !allowed.has(peer.id))) return;
|
|
558
|
-
this.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
857
|
+
this.queueFrame(session, wire, frameDigest, requestReply,
|
|
858
|
+
() => this.publicationCurrent(session, row.current.digest) && peers.every(peer => this.currentPresence(own.exposedPeers).some(value => value.id === peer.id)), true, undefined,
|
|
859
|
+
requestReply ? () => {
|
|
860
|
+
if (!session.presenceAsked) { session.pendingPresenceReplies = new Set(recipients); session.presenceAsked = true; }
|
|
861
|
+
session.requestPresenceReply = session.pendingPresenceReplies.size > 0;
|
|
862
|
+
if (this.retiredPresenceBytes && this.options.store.ordinaryInboxAvailable(this.options.name, this.retiredPresenceBytes)) this.retiredPresenceBytes = 0;
|
|
863
|
+
} : undefined);
|
|
864
|
+
session.lastPresence = presenceKey; sent = true;
|
|
563
865
|
});
|
|
564
866
|
} catch (error) {
|
|
565
867
|
this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.
|
|
@@ -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
|
+
}
|