@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
|
@@ -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
|
+
}
|
package/src/rooms/remote-peer.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { BasePeer, type DeliveryReceipt } from "../hub/peers.ts";
|
|
|
3
3
|
import type { Envelope, PeerState } from "../hub/envelope.ts";
|
|
4
4
|
import { encodeRoomMessage, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
|
|
5
5
|
import { readRoomOutbound, roomBoundary, type RoomBoundaryContext } from "./boundary.ts";
|
|
6
|
-
import { RoomNotSentError } from "./link.ts";
|
|
6
|
+
import { RoomCodecPendingError, RoomCodecUnsupportedError, RoomNotSentError } from "./link.ts";
|
|
7
7
|
|
|
8
8
|
export interface RemotePeerPorts {
|
|
9
9
|
member: string;
|
|
@@ -32,10 +32,12 @@ export class RemotePeer extends BasePeer {
|
|
|
32
32
|
private running = false;
|
|
33
33
|
private online = false;
|
|
34
34
|
private blockedNotSent = false;
|
|
35
|
+
private codecWaiting = false;
|
|
35
36
|
private observed: PeerState = "offline";
|
|
36
37
|
private readonly pending = new Set<string>();
|
|
37
38
|
private readonly waitingForReady = new Set<string>();
|
|
38
39
|
private readonly sending = new Set<string>();
|
|
40
|
+
private readonly codecRechecks = new Map<string, () => boolean>();
|
|
39
41
|
private readonly deadlines = new Map<string, number>();
|
|
40
42
|
constructor(id: string, readonly remoteRoom: string, readonly remoteQueueTtlMs: number, private readonly ports: RemotePeerPorts) {
|
|
41
43
|
super(id);
|
|
@@ -60,6 +62,7 @@ export class RemotePeer extends BasePeer {
|
|
|
60
62
|
// Active journal rows and their sealed room frames survive a sender restart.
|
|
61
63
|
// Stop only fences in-flight callbacks; it is not evidence of an uncertain send.
|
|
62
64
|
this.pending.clear();
|
|
65
|
+
this.codecWaiting = false; this.codecRechecks.clear();
|
|
63
66
|
this.waitingForReady.clear();
|
|
64
67
|
this.deadlines.clear(); this.update();
|
|
65
68
|
}
|
|
@@ -125,6 +128,7 @@ export class RemotePeer extends BasePeer {
|
|
|
125
128
|
}
|
|
126
129
|
/** A link becoming ready is an actual wake-up after a typed not-sent refusal. */
|
|
127
130
|
wake(): void { this.blockedNotSent = false; this.update(); }
|
|
131
|
+
wakeCodec(): void { if (this.codecWaiting) { this.codecWaiting = false; this.wake(); } }
|
|
128
132
|
setRemoteDeadline(envelopeId: string, expiresAt: number): void {
|
|
129
133
|
if (Number.isSafeInteger(expiresAt) && expiresAt > 0 && this.deadlines.size < 200) this.deadlines.set(envelopeId, expiresAt);
|
|
130
134
|
}
|
|
@@ -159,7 +163,11 @@ export class RemotePeer extends BasePeer {
|
|
|
159
163
|
if (this.sending.has(deliveryId)) return;
|
|
160
164
|
this.sending.add(deliveryId);
|
|
161
165
|
try { await this.publishOnce(env, deliveryId, expiresAt, generation, immutable); }
|
|
162
|
-
finally {
|
|
166
|
+
finally {
|
|
167
|
+
this.sending.delete(deliveryId);
|
|
168
|
+
const changed = this.codecRechecks.get(deliveryId); this.codecRechecks.delete(deliveryId);
|
|
169
|
+
if (changed?.()) { this.codecWaiting = false; this.wake(); } // Recheck only after this send lane released, closing observation-before-requeue races.
|
|
170
|
+
}
|
|
163
171
|
}
|
|
164
172
|
private async publishOnce(env: Envelope, deliveryId: string, expiresAt: number, generation: number, immutable?: RoomChatEnvelope): Promise<void> {
|
|
165
173
|
let context: RoomBoundaryContext;
|
|
@@ -216,12 +224,19 @@ export class RemotePeer extends BasePeer {
|
|
|
216
224
|
}
|
|
217
225
|
} catch (error) {
|
|
218
226
|
if (!this.pending.has(deliveryId)) return;
|
|
227
|
+
if (error instanceof RoomCodecUnsupportedError && !immutable) {
|
|
228
|
+
let queued: boolean | undefined;
|
|
229
|
+
try { queued = this.ports.queued?.(deliveryId); } catch { queued = undefined; }
|
|
230
|
+
if (queued === false) { this.receipt({ id: deliveryId, state: "failed_safe", reason: error.message }); return; }
|
|
231
|
+
}
|
|
232
|
+
if (error instanceof RoomCodecPendingError) this.codecWaiting = true;
|
|
219
233
|
if (error instanceof RoomNotSentError) {
|
|
220
234
|
let retained: boolean | undefined;
|
|
221
235
|
try { retained = this.ports.queued?.(deliveryId); } catch { retained = undefined; }
|
|
222
236
|
if (immutable || retained === true) {
|
|
223
|
-
if (!this.ready()) this.waitForReady(deliveryId);
|
|
237
|
+
if (!this.ready() || error instanceof RoomCodecPendingError) this.waitForReady(deliveryId);
|
|
224
238
|
} else this.notSent(deliveryId);
|
|
239
|
+
if (error instanceof RoomCodecPendingError) this.codecRechecks.set(deliveryId, error.changed);
|
|
225
240
|
return;
|
|
226
241
|
}
|
|
227
242
|
if (!trusted()) {
|
package/src/rooms/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Host integration for X4. Remote identities carry chat, never local control authority. */
|
|
2
|
-
import { ROOM_PROOF_UNAVAILABLE_REASON, type Bus } from "../hub/bus.ts";
|
|
2
|
+
import { ROOM_EPOCH_ROTATED_REASON, ROOM_PROOF_UNAVAILABLE_REASON, type Bus } from "../hub/bus.ts";
|
|
3
3
|
import { USER, MAX_HOP, isInvocationKind, type Envelope, type PeerState } from "../hub/envelope.ts";
|
|
4
4
|
import type { RoomInvocationAuthorization } from "./boundary.ts";
|
|
5
5
|
import { roomInvocationHash } from "../hub/room-invocations.ts";
|
|
@@ -11,8 +11,9 @@ import { roomMembers, RoomWireError } from "./wire.ts";
|
|
|
11
11
|
import { RoomStore, RoomStoreError, RoomApplicationLimitError, type RoomOutgoing } from "./store.ts";
|
|
12
12
|
import { RoomPolicy } from "./policy.ts";
|
|
13
13
|
import { RemotePeer } from "./remote-peer.ts";
|
|
14
|
-
import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } from "./application.ts";
|
|
14
|
+
import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope, type RoomCodecFeature } from "./application.ts";
|
|
15
15
|
import { RoomApplicationDeferredError, RoomNotSentError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
|
|
16
|
+
import { ROOM_PRESENCE_MISSES, ROOM_REFRESH_MS, roomRefreshPeriod } from "./liveness.ts";
|
|
16
17
|
|
|
17
18
|
export interface RoomInvocationIdentity { room: string; member: string; epoch: string; current: () => boolean }
|
|
18
19
|
export interface RoomInvocationIngress {
|
|
@@ -20,7 +21,7 @@ export interface RoomInvocationIngress {
|
|
|
20
21
|
current: () => boolean; claim: () => boolean;
|
|
21
22
|
}
|
|
22
23
|
export interface RoomChatOptions {
|
|
23
|
-
bus: Bus; cwd: string; now?: () => number;
|
|
24
|
+
bus: Bus; cwd: string; now?: () => number; refreshMs?: number;
|
|
24
25
|
routing: () => Routing; piiPaths: () => readonly string[];
|
|
25
26
|
senderPrivate: (peer: string) => boolean | undefined;
|
|
26
27
|
taskPrivate: (id: number) => boolean | undefined;
|
|
@@ -31,10 +32,10 @@ export interface RoomChatOptions {
|
|
|
31
32
|
roster?: (ids: readonly string[]) => void;
|
|
32
33
|
changed?: () => void;
|
|
33
34
|
limits?: () => LimitsConfig;
|
|
34
|
-
notice?: (value: { code: "application_capacity"; room: string; count: number }
|
|
35
|
+
notice?: (value: { code: "application_capacity"; room: string; count: number }) => void;
|
|
35
36
|
}
|
|
36
37
|
export interface RoomMetrics { since: number; outbound: number | null; inbound: number | null; withheld: number | null }
|
|
37
|
-
interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; online: boolean; states: Map<string, PeerState
|
|
38
|
+
interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; rosterMembers: string; online: boolean; states: Map<string, PeerState>; observedAt: Map<string, number>; observedMembers: Set<string>; tracked?: boolean }
|
|
38
39
|
interface Target { binding: Binding; member: string; peer: string }
|
|
39
40
|
const ACTIVE = new Set(["dispatching", "accepted", "needs_review"]);
|
|
40
41
|
function completedText(reason?: RoomReceiptReason): string {
|
|
@@ -69,6 +70,9 @@ export class RoomChat {
|
|
|
69
70
|
private readonly outgoing = new Map<string, { binding: Binding; row: RoomOutgoing }>();
|
|
70
71
|
private readonly resuming = new Set<string>();
|
|
71
72
|
private readonly personHeldInvocations = new Map<string, Set<string>>();
|
|
73
|
+
private readonly refreshMs: number;
|
|
74
|
+
private presenceClockHighWater?: number;
|
|
75
|
+
private presenceClockInvalid = false;
|
|
72
76
|
private evidenceCache?: Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>;
|
|
73
77
|
private timer?: ReturnType<typeof setTimeout>;
|
|
74
78
|
private stopped = false;
|
|
@@ -78,8 +82,9 @@ export class RoomChat {
|
|
|
78
82
|
readonly application: RoomLinkApplication;
|
|
79
83
|
constructor(private readonly options: RoomChatOptions) {
|
|
80
84
|
this.policy = new RoomPolicy(options.cwd);
|
|
85
|
+
this.refreshMs = roomRefreshPeriod(options.refreshMs ?? ROOM_REFRESH_MS);
|
|
81
86
|
let since: number | null = null;
|
|
82
|
-
try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) since = value; } catch { /* Observation failure never disables transport construction. */ }
|
|
87
|
+
try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) { since = value; this.presenceClockHighWater = value; } } catch { /* Observation failure never disables transport construction. */ }
|
|
83
88
|
this.metricsSince = since;
|
|
84
89
|
this.application = {
|
|
85
90
|
members: context => { if (context.current()) this.bind(context.store, context.name, context.link); },
|
|
@@ -101,14 +106,53 @@ export class RoomChat {
|
|
|
101
106
|
const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding);
|
|
102
107
|
},
|
|
103
108
|
capacityChanged: context => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding); this.capacityChanged(); } },
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
codecsChanged: (context, input) => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) { this.wakeRemotes(binding, input.member, true); void this.resumeActive(binding, input.member, input.features); } } },
|
|
110
|
+
// The signed sequence is room-wide, while chat recipients are selective.
|
|
111
|
+
// A numeric gap is not evidence of loss and must not become an operator notice.
|
|
107
112
|
};
|
|
108
113
|
}
|
|
109
114
|
private now(): number { return this.options.now?.() ?? Date.now(); }
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
/** Local receipt time only; remote timestamps never participate in freshness. */
|
|
116
|
+
private presenceNow(mutate = true): number | undefined {
|
|
117
|
+
let now: number;
|
|
118
|
+
try { now = this.now(); } catch { now = NaN; }
|
|
119
|
+
if (!Number.isSafeInteger(now) || now < 0 || this.presenceClockHighWater !== undefined && now < this.presenceClockHighWater) {
|
|
120
|
+
if (mutate && !this.presenceClockInvalid) {
|
|
121
|
+
for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) binding.observedAt.clear();
|
|
122
|
+
}
|
|
123
|
+
if (mutate) this.presenceClockInvalid = true;
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (mutate) {
|
|
127
|
+
if (this.presenceClockHighWater === undefined || now > this.presenceClockHighWater) this.presenceClockHighWater = now;
|
|
128
|
+
this.presenceClockInvalid = false;
|
|
129
|
+
}
|
|
130
|
+
return now;
|
|
131
|
+
}
|
|
132
|
+
private livenessTracked(binding: Binding): boolean | undefined {
|
|
133
|
+
return binding.tracked;
|
|
134
|
+
}
|
|
135
|
+
private memberFresh(binding: Binding, member: string, now: number | undefined): boolean {
|
|
136
|
+
if (this.livenessTracked(binding) === false) return true;
|
|
137
|
+
if (now === undefined || this.presenceClockInvalid) return false;
|
|
138
|
+
const seen = binding.observedAt.get(member);
|
|
139
|
+
return seen !== undefined && now >= seen && now - seen < this.refreshMs * ROOM_PRESENCE_MISSES;
|
|
140
|
+
}
|
|
141
|
+
private advisoryPresence(id: string, target: Target): PeerState | "unknown" {
|
|
142
|
+
if (this.livenessTracked(target.binding) !== false && !this.memberFresh(target.binding, target.member, this.presenceNow(false))) return "unknown";
|
|
143
|
+
return target.peer === USER ? "idle" : target.binding.states.get(id) ?? "offline";
|
|
144
|
+
}
|
|
145
|
+
private advisoryLiveness(target: Target): "untracked" | undefined {
|
|
146
|
+
return this.livenessTracked(target.binding) === false ? "untracked" : undefined;
|
|
147
|
+
}
|
|
148
|
+
private targetSendable(id: string): boolean {
|
|
149
|
+
const target = this.targets.get(id);
|
|
150
|
+
return !!target && target.binding.online && this.valid(target.binding);
|
|
151
|
+
}
|
|
152
|
+
private wakeRemotes(binding: Binding, member?: string, codecOnly = false): void {
|
|
153
|
+
for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding && (member === undefined || this.targets.get(id)?.member === member)) {
|
|
154
|
+
if (codecOnly) remote.wakeCodec(); else remote.wake();
|
|
155
|
+
}
|
|
112
156
|
}
|
|
113
157
|
metrics(name: string, room?: string): RoomMetrics | null {
|
|
114
158
|
const binding = this.bindings.get(name);
|
|
@@ -145,11 +189,18 @@ export class RoomChat {
|
|
|
145
189
|
bind(store: RoomStore, name: string, link: RoomLink): void {
|
|
146
190
|
if (this.stopped) return;
|
|
147
191
|
const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
|
|
148
|
-
const room = state.current.room, counted = this.counts.get(name);
|
|
192
|
+
const room = state.current.room, rosterMembers = roomMembers(state.current).map(member => member.fingerprint).sort().join(","), counted = this.counts.get(name);
|
|
149
193
|
if (counted?.room !== room && (counted || this.counts.size < 64)) this.counts.set(name, { room, outbound: 0, inbound: 0, withheld: 0 });
|
|
150
194
|
const saved = this.bindings.get(name);
|
|
151
|
-
const binding = saved?.store === store && saved.link === link && saved.room === room ? saved : {
|
|
152
|
-
|
|
195
|
+
const binding: Binding = saved?.store === store && saved.link === link && saved.room === room ? saved : {
|
|
196
|
+
store, name, room, link, digest, rosterMembers, online: false, states: new Map<string, PeerState>(), observedAt: new Map<string, number>(), observedMembers: new Set<string>(),
|
|
197
|
+
};
|
|
198
|
+
const tracked = link.livenessTracked;
|
|
199
|
+
if (binding.digest !== digest || binding.rosterMembers !== rosterMembers
|
|
200
|
+
|| tracked !== undefined && binding.tracked !== undefined && tracked !== binding.tracked) {
|
|
201
|
+
binding.states.clear(); binding.observedAt.clear(); binding.observedMembers.clear(); binding.online = false; binding.digest = digest; binding.rosterMembers = rosterMembers;
|
|
202
|
+
}
|
|
203
|
+
if (tracked !== undefined) binding.tracked = tracked;
|
|
153
204
|
this.bindings.set(name, binding);
|
|
154
205
|
this.reconcileDispositions(binding);
|
|
155
206
|
this.refreshInvocationHolds(binding);
|
|
@@ -165,6 +216,14 @@ export class RoomChat {
|
|
|
165
216
|
} };
|
|
166
217
|
}
|
|
167
218
|
private refreshTargets(): void {
|
|
219
|
+
const now = this.presenceNow();
|
|
220
|
+
// Consume an expired observation once. Keeping its timestamp makes every
|
|
221
|
+
// later queue callback revisit this branch and can re-reserve settled rows.
|
|
222
|
+
if (now !== undefined) for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) {
|
|
223
|
+
for (const [member, seenAt] of binding.observedAt) {
|
|
224
|
+
if (now >= seenAt && now - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES) binding.observedAt.delete(member);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
168
227
|
const candidates = new Map<string, Target[]>();
|
|
169
228
|
for (const binding of this.bindings.values()) {
|
|
170
229
|
const state = binding.store.get(binding.name);
|
|
@@ -225,7 +284,7 @@ export class RoomChat {
|
|
|
225
284
|
this.count(binding, "outbound", withheld === true);
|
|
226
285
|
}
|
|
227
286
|
finally { this.refreshOutgoing(binding); this.queueChanged(); }
|
|
228
|
-
}, ready: () => this.options.bus.remoteSendAllowed(remote!), waiting: () => this.queueChanged(),
|
|
287
|
+
}, ready: () => this.options.bus.remoteSendAllowed(remote!) && this.targetSendable(id), waiting: () => this.queueChanged(),
|
|
229
288
|
notSent: deliveryId => this.options.bus.requeueRemoteNotSent(remote!, deliveryId),
|
|
230
289
|
queued: deliveryId => {
|
|
231
290
|
const selected = this.targets.get(id);
|
|
@@ -292,7 +351,8 @@ export class RoomChat {
|
|
|
292
351
|
}
|
|
293
352
|
for (const [id, remote] of this.remotes) {
|
|
294
353
|
const target = this.targets.get(id);
|
|
295
|
-
|
|
354
|
+
const state = target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline";
|
|
355
|
+
remote.presence(state, target?.binding.online === true);
|
|
296
356
|
}
|
|
297
357
|
this.options.roster?.([...this.targets.keys()]);
|
|
298
358
|
}
|
|
@@ -302,14 +362,23 @@ export class RoomChat {
|
|
|
302
362
|
&& this.policy.get(binding.name).includes(peer)
|
|
303
363
|
&& roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
|
|
304
364
|
}
|
|
305
|
-
presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[] }): void {
|
|
365
|
+
presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true; observedAt?: number | null }): void {
|
|
306
366
|
const binding = this.bindings.get(name), state = store.get(name);
|
|
307
367
|
if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
|
|
308
368
|
const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
|
|
309
369
|
if (!member) return;
|
|
370
|
+
if (value.restored && binding.observedMembers.has(value.member)) return;
|
|
310
371
|
for (const peer of member.exposedPeers) binding.states.delete(`${peer}@${member.label}`);
|
|
311
372
|
for (const peer of value.peers) if (member.exposedPeers.includes(peer.id)) binding.states.set(`${peer.id}@${member.label}`, peer.state);
|
|
373
|
+
const now = this.presenceNow();
|
|
374
|
+
if (value.restored !== true) {
|
|
375
|
+
binding.observedMembers.add(member.fingerprint);
|
|
376
|
+
const receivedAt = Object.hasOwn(value, "observedAt") ? value.observedAt : now;
|
|
377
|
+
if (typeof receivedAt === "number" && Number.isSafeInteger(receivedAt) && receivedAt >= 0 && now !== undefined && receivedAt <= now) binding.observedAt.set(member.fingerprint, receivedAt);
|
|
378
|
+
else binding.observedAt.delete(member.fingerprint);
|
|
379
|
+
}
|
|
312
380
|
this.refreshTargets();
|
|
381
|
+
this.queueChanged();
|
|
313
382
|
}
|
|
314
383
|
linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
|
|
315
384
|
// stop() fences callbacks as part of ordinary shutdown; it is not a room
|
|
@@ -339,6 +408,9 @@ export class RoomChat {
|
|
|
339
408
|
return [...new Set([...this.outgoing.values()].filter(({ binding, row }) => binding.store.get(binding.name)?.current.room === room && ACTIVE.has(row.state) && !row.disposition).map(({ row }) => row.application))];
|
|
340
409
|
}
|
|
341
410
|
private outcome(binding: Binding, row: RoomOutgoing): void {
|
|
411
|
+
// Refresh this snapshot before settlement callbacks synchronously re-enter
|
|
412
|
+
// queueChanged through Bus.onQueues.
|
|
413
|
+
this.refreshOutgoing(binding);
|
|
342
414
|
const remote = this.remotes.get(row.remotePeer);
|
|
343
415
|
if (row.state === "needs_review" && !row.disposition) {
|
|
344
416
|
const journalRow = this.options.bus.queueShow(row.deliveryId);
|
|
@@ -356,6 +428,7 @@ export class RoomChat {
|
|
|
356
428
|
}
|
|
357
429
|
private settleStoredOutcome(binding: Binding, remote: RemotePeer | undefined, row: RoomOutgoing): boolean {
|
|
358
430
|
if ((row.state !== "completed" && row.state !== "failed_safe") || row.disposition) return false;
|
|
431
|
+
this.refreshOutgoing(binding);
|
|
359
432
|
const outcome = row.lateReceipt?.outcome ?? row.state;
|
|
360
433
|
const reason = row.lateReceipt?.reason ?? row.reason ?? (outcome === "completed" ? "admitted" : "boundary_refused");
|
|
361
434
|
const journalRow = this.options.bus.queueShow(row.deliveryId);
|
|
@@ -433,6 +506,7 @@ export class RoomChat {
|
|
|
433
506
|
if (!this.valid(binding)) return;
|
|
434
507
|
let rows: RoomOutgoing[];
|
|
435
508
|
try { rows = binding.store.applicationOutbox(binding.name); } catch { return; }
|
|
509
|
+
this.refreshOutgoing(binding);
|
|
436
510
|
for (const row of rows) {
|
|
437
511
|
if (row.disposition) continue;
|
|
438
512
|
const remote = this.remotes.get(row.remotePeer);
|
|
@@ -443,7 +517,7 @@ export class RoomChat {
|
|
|
443
517
|
const current = this.currentOutgoingRow(binding, row);
|
|
444
518
|
if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) continue;
|
|
445
519
|
const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current);
|
|
446
|
-
if (quarantined) remote.settled(row.deliveryId);
|
|
520
|
+
if (quarantined) { this.refreshOutgoing(binding); remote.settled(row.deliveryId); }
|
|
447
521
|
} else if (row.state === "needs_review") this.holdStoreNeedsReview(binding, row, remote);
|
|
448
522
|
}
|
|
449
523
|
this.refreshOutgoing(binding);
|
|
@@ -566,6 +640,16 @@ export class RoomChat {
|
|
|
566
640
|
if (this.timer) clearTimeout(this.timer);
|
|
567
641
|
this.timer = undefined; if (this.stopped || !this.bindings.size) return;
|
|
568
642
|
let next = this.options.bus.nextRemoteExpiry();
|
|
643
|
+
const presenceNow = this.presenceNow();
|
|
644
|
+
if (presenceNow !== undefined) {
|
|
645
|
+
if ([...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
|
|
646
|
+
presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
|
|
647
|
+
for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) for (const seenAt of binding.observedAt.values()) {
|
|
648
|
+
const staleAt = seenAt + this.refreshMs * ROOM_PRESENCE_MISSES;
|
|
649
|
+
if (staleAt <= presenceNow) continue;
|
|
650
|
+
next = next === undefined ? staleAt : Math.min(next, staleAt);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
569
653
|
for (const { binding, row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
|
|
570
654
|
const due = Math.min(row.expiresAt, binding.online ? row.ackDeadline ?? Infinity : Infinity); next = next === undefined ? due : Math.min(next, due);
|
|
571
655
|
}
|
|
@@ -588,6 +672,9 @@ export class RoomChat {
|
|
|
588
672
|
}
|
|
589
673
|
tick(): void {
|
|
590
674
|
if (this.stopped) return;
|
|
675
|
+
const presenceNow = this.presenceNow();
|
|
676
|
+
if (presenceNow === undefined || [...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
|
|
677
|
+
presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
|
|
591
678
|
this.options.bus.expireRemoteQueues(this.now());
|
|
592
679
|
for (const binding of this.bindings.values()) if (this.valid(binding)) {
|
|
593
680
|
try {
|
|
@@ -603,11 +690,18 @@ export class RoomChat {
|
|
|
603
690
|
}
|
|
604
691
|
void this.retryPending(); this.queueChanged();
|
|
605
692
|
}
|
|
606
|
-
private async resumeActive(binding: Binding): Promise<void> {
|
|
693
|
+
private async resumeActive(binding: Binding, codecMember?: string, changedFeatures?: readonly RoomCodecFeature[]): Promise<void> {
|
|
607
694
|
if (!binding.online || !this.valid(binding)) return;
|
|
608
695
|
try {
|
|
609
|
-
const active = binding.store.applicationOutbox(binding.name, true).filter(row =>
|
|
610
|
-
&&
|
|
696
|
+
const active = binding.store.applicationOutbox(binding.name, true).filter(row => {
|
|
697
|
+
if (row.state !== "dispatching" && row.state !== "accepted" || this.personHeldInvocations.get(binding.name)?.has(row.deliveryId)) return false;
|
|
698
|
+
if (codecMember === undefined) return true;
|
|
699
|
+
if (row.recipient !== codecMember) return false;
|
|
700
|
+
let required: RoomCodecFeature | undefined;
|
|
701
|
+
if (row.peer === USER) required = "person_inbox_v1";
|
|
702
|
+
else if (this.options.bus.queueShow(row.deliveryId)?.originals.some(isInvocationKind)) required = "read_invocation_v1";
|
|
703
|
+
return required !== undefined && (changedFeatures === undefined || changedFeatures.includes(required));
|
|
704
|
+
});
|
|
611
705
|
for (const row of active) this.remotes.get(row.remotePeer)?.reservePending(row.deliveryId);
|
|
612
706
|
for (const row of active) this.launchResume(binding, row);
|
|
613
707
|
} catch { /* The next authenticated online edge will retry durable rows. */ }
|
|
@@ -664,6 +758,10 @@ export class RoomChat {
|
|
|
664
758
|
const digest = state?.current.digest;
|
|
665
759
|
const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
|
|
666
760
|
if (!target || !state || target.binding !== binding || !proofCurrent()) { remote.deferResume(row.deliveryId); return; }
|
|
761
|
+
let required: RoomCodecFeature | undefined;
|
|
762
|
+
if (isInvocationKind(original)) required = "read_invocation_v1";
|
|
763
|
+
else if (row.peer === USER) required = "person_inbox_v1";
|
|
764
|
+
if (required && !binding.link.codecSupported(row.recipient, required)) { remote.deferResume(row.deliveryId); return; }
|
|
667
765
|
const ready = () => binding.online && this.options.bus.remoteSendAllowed(remote);
|
|
668
766
|
if (!ready()) { remote.waitForReady(row.deliveryId); return; }
|
|
669
767
|
let readinessLost = false;
|
|
@@ -713,16 +811,18 @@ export class RoomChat {
|
|
|
713
811
|
}
|
|
714
812
|
private reconcileDispositions(binding: Binding): boolean {
|
|
715
813
|
let complete = true;
|
|
814
|
+
const settled: Array<{ peer: string; deliveryId: string }> = [];
|
|
716
815
|
for (const row of binding.store.applicationOutbox(binding.name)) if (!row.disposition) {
|
|
717
816
|
const action = this.options.bus.remoteDisposition(row.deliveryId);
|
|
718
817
|
if (action) try {
|
|
719
818
|
const disposed = binding.store.disposeApplication(binding.name, row.deliveryId, action, true, () => this.valid(binding)
|
|
720
819
|
&& binding.store.get(binding.name)?.current.digest === binding.digest && this.options.bus.remoteDisposition(row.deliveryId) === action);
|
|
721
|
-
if (disposed?.disposition === action)
|
|
820
|
+
if (disposed?.disposition === action) settled.push({ peer: row.remotePeer, deliveryId: row.deliveryId });
|
|
722
821
|
}
|
|
723
822
|
catch { complete = false; }
|
|
724
823
|
}
|
|
725
824
|
this.refreshOutgoing(binding);
|
|
825
|
+
for (const row of settled) this.remotes.get(row.peer)?.settled(row.deliveryId);
|
|
726
826
|
return complete;
|
|
727
827
|
}
|
|
728
828
|
disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
|
|
@@ -734,7 +834,10 @@ export class RoomChat {
|
|
|
734
834
|
const disposed = binding.store.disposeApplication(binding.name, deliveryId, action, true, () => this.valid(binding)
|
|
735
835
|
&& binding.store.get(binding.name)?.current.room === room && binding.store.get(binding.name)?.current.digest === digest
|
|
736
836
|
&& this.options.bus.remoteDisposition(deliveryId) === action);
|
|
737
|
-
if (disposed?.disposition === action)
|
|
837
|
+
if (disposed?.disposition === action) {
|
|
838
|
+
this.refreshOutgoing(binding);
|
|
839
|
+
this.remotes.get(peer)?.settled(deliveryId);
|
|
840
|
+
}
|
|
738
841
|
this.personHeldInvocations.get(binding.name)?.delete(deliveryId);
|
|
739
842
|
this.refreshOutgoing(binding); break;
|
|
740
843
|
}
|
|
@@ -756,20 +859,56 @@ export class RoomChat {
|
|
|
756
859
|
const row = this.evidenceCache.get(deliveryId); if (!row || row.peer !== peer) return undefined;
|
|
757
860
|
const { peer: _peer, ...view } = row; return view;
|
|
758
861
|
}
|
|
862
|
+
/** Hold only the sealed intents captured by the person's committed epoch change. */
|
|
863
|
+
epochRotated(store: RoomStore, name: string, previousDigest: string, expected: readonly RoomOutgoing[]): boolean {
|
|
864
|
+
const state = store.get(name);
|
|
865
|
+
if (this.stopped || !state || state.left || state.current.digest === previousDigest) return false;
|
|
866
|
+
const binding = this.bindings.get(name);
|
|
867
|
+
if (binding?.store === store && binding.room === state.current.room) {
|
|
868
|
+
// Releasing an old FIFO head must not drain queued work using old presence.
|
|
869
|
+
binding.online = false; binding.states.clear(); binding.observedAt.clear();
|
|
870
|
+
for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.presence("offline", false);
|
|
871
|
+
}
|
|
872
|
+
let complete = true;
|
|
873
|
+
for (const before of expected) {
|
|
874
|
+
const current = () => {
|
|
875
|
+
const room = store.get(name), row = store.applicationOutbox(name).find(row => row.deliveryId === before.deliveryId);
|
|
876
|
+
return !this.stopped && !!room && !room.left && room.current.digest === state.current.digest
|
|
877
|
+
&& !!row && row.application === before.application && row.remotePeer === before.remotePeer && row.recipient === before.recipient
|
|
878
|
+
&& row.state === "needs_review" && row.reason === "boundary_refused" && !row.disposition;
|
|
879
|
+
};
|
|
880
|
+
if (!current()) { complete = false; continue; }
|
|
881
|
+
if (!this.options.bus.holdRemoteEpochRotated(before.remotePeer, before.deliveryId, state.current.room, before.recipient, current)) {
|
|
882
|
+
complete = false; continue;
|
|
883
|
+
}
|
|
884
|
+
const remote = this.remotes.get(before.remotePeer);
|
|
885
|
+
if (remote?.remoteRoom === state.current.room && remote.remoteMember === before.recipient) remote.settled(before.deliveryId);
|
|
886
|
+
}
|
|
887
|
+
this.evidenceCache = undefined;
|
|
888
|
+
return complete;
|
|
889
|
+
}
|
|
759
890
|
rows(name: string): Record<string, unknown>[] {
|
|
760
891
|
const binding = this.bindings.get(name); if (!binding) return [];
|
|
761
892
|
const incoming = binding.store.applicationStatus(name);
|
|
762
|
-
|
|
893
|
+
const outgoing = binding.store.applicationOutbox(name).sort((a, b) => Number(!ACTIVE.has(a.state) || !!a.disposition) - Number(!ACTIVE.has(b.state) || !!b.disposition) || b.createdAt - a.createdAt).slice(0, 200), epochHolds = new Set<string>();
|
|
894
|
+
for (const peer of new Set(outgoing.map(row => row.remotePeer))) {
|
|
895
|
+
for (const row of this.options.bus.queueList(peer, true)) if (row.state === "needs_review" && row.reason === `${ROOM_EPOCH_ROTATED_REASON} ${row.id}`) epochHolds.add(row.id);
|
|
896
|
+
}
|
|
897
|
+
return [...outgoing.map(row => ({ direction: "outbound", id: row.deliveryId, application: row.application, peer: row.remotePeer, state: row.state === "completed" ? completedText(row.reason) : row.state, ...(row.disposition ? { disposition: row.disposition } : {}), ...(row.lateReceipt ? { lateReceipt: lateReceiptText(row.lateReceipt) } : {}), ...(epochHolds.has(row.deliveryId) ? { holdReason: "epoch rotated" } : refusalReason(row) ? { refusalReason: refusalReason(row) } : {}) })),
|
|
763
898
|
...incoming.rows.map(row => ({ direction: "inbound", id: row.digest, application: row.application, state: row.state === "disposed" ? `disposed (${row.disposition}, by person)` : row.state, peer: row.peer, ...(row.disposedAt ? { disposedAt: row.disposedAt } : {}) })),
|
|
764
899
|
{ direction: "inbound", pending: incoming.pending, needsReview: incoming.needsReview, truncated: incoming.truncated }];
|
|
765
900
|
}
|
|
766
|
-
peers(name: string): { id: string; state: PeerState }[] {
|
|
767
|
-
return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER)
|
|
901
|
+
peers(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] {
|
|
902
|
+
return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER)
|
|
903
|
+
.map(([id, target]) => ({ id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) })).sort((a, b) => a.id.localeCompare(b.id));
|
|
768
904
|
}
|
|
769
905
|
/** A bounded status list, separate from the explicit detail query above. */
|
|
770
|
-
peersForStatus(name: string): { id: string; state: PeerState }[] | null {
|
|
906
|
+
peersForStatus(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] | null {
|
|
771
907
|
const ids = this.statusTargets.get(name);
|
|
772
|
-
return ids ? ids.map(id =>
|
|
908
|
+
return ids ? ids.map(id => {
|
|
909
|
+
const target = this.targets.get(id)!;
|
|
910
|
+
return { id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) };
|
|
911
|
+
}) : null;
|
|
773
912
|
}
|
|
774
913
|
stop(): void {
|
|
775
914
|
if (this.stopped) return; this.stopped = true;
|
package/src/rooms/status.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { types } from "node:util";
|
|
|
2
2
|
import type { PeerState } from "../hub/envelope.ts";
|
|
3
3
|
import { decodeBase64url, encodeBase64url } from "./crypto.ts";
|
|
4
4
|
import { parseRemotePeerId } from "./application.ts";
|
|
5
|
+
import { ROOM_HEALTH_CAUSES, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
|
|
5
6
|
|
|
6
7
|
const ROOM_LIMIT = 64;
|
|
7
8
|
const REMOTE_PEER_LIMIT = 4096;
|
|
@@ -15,9 +16,18 @@ const EPOCH = /^[1-9][0-9]{0,19}$/;
|
|
|
15
16
|
const LINK_STATES = new Set(["online", "connecting", "offline", "left"]);
|
|
16
17
|
const PEER_STATES = new Set<PeerState>(["idle", "busy", "paused", "offline"]);
|
|
17
18
|
|
|
19
|
+
export interface RoomStatusHealth {
|
|
20
|
+
refused: { invalid_presence: number; wrong_epoch: number; trust_changed: number; stale: number; invalid_frame: number } | null;
|
|
21
|
+
outboxDepth: number | null;
|
|
22
|
+
oldestOutboxAgeMs: number | null;
|
|
23
|
+
lastReceiptAgeMs: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
export interface RoomStatusPeer {
|
|
19
27
|
id: string;
|
|
20
28
|
state: PeerState;
|
|
29
|
+
presence?: PeerState | "unknown";
|
|
30
|
+
liveness?: "untracked";
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
export interface RoomRelayStats {
|
|
@@ -27,8 +37,15 @@ export interface RoomRelayStats {
|
|
|
27
37
|
withheld: number | null;
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
export interface RoomStatusDiagnostics {
|
|
41
|
+
health?: RoomStatusHealth | null;
|
|
42
|
+
linkCause?: RoomHealthCause | null;
|
|
43
|
+
linkNextStep?: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface RoomStatusUnavailable { code: RoomHealthCause | null; nextStep: string | null }
|
|
46
|
+
|
|
30
47
|
/** Allowlisted room metadata. Missing member counts and peer lists stay unknown. */
|
|
31
|
-
export interface RoomStatusView {
|
|
48
|
+
export interface RoomStatusView extends RoomStatusDiagnostics {
|
|
32
49
|
name: string;
|
|
33
50
|
room: string;
|
|
34
51
|
owner: string;
|
|
@@ -95,8 +112,13 @@ function remotePeers(value: unknown, remaining: number): RoomStatusPeer[] | null
|
|
|
95
112
|
if (!plainRecord(item)) return null;
|
|
96
113
|
const id = ownData(item, "id"), state = ownData(item, "state");
|
|
97
114
|
if (typeof id !== "string" || !parseRemotePeerId(id) || typeof state !== "string" || !PEER_STATES.has(state as PeerState) || ids.has(id)) return null;
|
|
115
|
+
const presenceField = ownField(item, "presence"), livenessField = ownField(item, "liveness");
|
|
116
|
+
if (presenceField.present && !Object.hasOwn(presenceField, "value") || livenessField.present && !Object.hasOwn(livenessField, "value")) return null;
|
|
117
|
+
const presence = presenceField.value, liveness = livenessField.value;
|
|
118
|
+
if (presence !== undefined && presence !== "unknown" && (typeof presence !== "string" || !PEER_STATES.has(presence as PeerState))) return null;
|
|
119
|
+
if (liveness !== undefined && liveness !== "untracked") return null;
|
|
98
120
|
ids.add(id);
|
|
99
|
-
result.push({ id, state: state as PeerState });
|
|
121
|
+
result.push({ id, state: state as PeerState, ...(presence !== undefined ? { presence: presence as RoomStatusPeer["presence"] } : {}), ...(liveness === "untracked" ? { liveness } : {}) });
|
|
100
122
|
}
|
|
101
123
|
return result;
|
|
102
124
|
}
|
|
@@ -116,6 +138,46 @@ function relayStats(value: unknown): RoomRelayStats | null {
|
|
|
116
138
|
return { since, outbound, inbound, withheld };
|
|
117
139
|
}
|
|
118
140
|
|
|
141
|
+
/** Counts and ages remain unknown when an older daemon cannot report them. */
|
|
142
|
+
export function projectRoomHealth(value: unknown): RoomStatusHealth | null {
|
|
143
|
+
if (!plainRecord(value)) return null;
|
|
144
|
+
const rawRefused = ownData(value, "refused");
|
|
145
|
+
let refused: RoomStatusHealth["refused"] = null;
|
|
146
|
+
if (plainRecord(rawRefused)) {
|
|
147
|
+
const invalid_presence = relayCount(ownData(rawRefused, "invalid_presence")), wrong_epoch = relayCount(ownData(rawRefused, "wrong_epoch"));
|
|
148
|
+
const trust_changed = relayCount(ownData(rawRefused, "trust_changed")), stale = relayCount(ownData(rawRefused, "stale")), invalid_frame = relayCount(ownData(rawRefused, "invalid_frame"));
|
|
149
|
+
if (invalid_presence !== null && wrong_epoch !== null && trust_changed !== null && stale !== null && invalid_frame !== null)
|
|
150
|
+
refused = { invalid_presence, wrong_epoch, trust_changed, stale, invalid_frame };
|
|
151
|
+
}
|
|
152
|
+
return { refused, outboxDepth: relayCount(ownData(value, "outboxDepth")),
|
|
153
|
+
oldestOutboxAgeMs: relayCount(ownData(value, "oldestOutboxAgeMs")), lastReceiptAgeMs: relayCount(ownData(value, "lastReceiptAgeMs")) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function healthCause(value: unknown): RoomHealthCause | null {
|
|
157
|
+
return typeof value === "string" && ROOM_HEALTH_CAUSES.includes(value as RoomHealthCause) ? value as RoomHealthCause : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Regenerate guidance from closed causes; arbitrary daemon text is never copied. */
|
|
161
|
+
export function projectRoomDiagnostics(value: unknown): RoomStatusDiagnostics {
|
|
162
|
+
if (!plainRecord(value)) return {};
|
|
163
|
+
const cause = healthCause(ownData(value, "linkCause"));
|
|
164
|
+
const result: RoomStatusDiagnostics = {};
|
|
165
|
+
if (ownField(value, "health").present) result.health = projectRoomHealth(ownData(value, "health"));
|
|
166
|
+
if (ownField(value, "linkCause").present) {
|
|
167
|
+
result.linkCause = cause;
|
|
168
|
+
result.linkNextStep = null;
|
|
169
|
+
if (cause !== null) result.linkNextStep = roomHealthNextStep(cause);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Absent or malformed availability metadata stays unknown without evaluating getters. */
|
|
175
|
+
export function projectRoomUnavailable(value: unknown): RoomStatusUnavailable | null {
|
|
176
|
+
if (!plainRecord(value)) return null;
|
|
177
|
+
const code = healthCause(ownData(value, "code"));
|
|
178
|
+
return { code, nextStep: code === null ? null : roomHealthNextStep(code) };
|
|
179
|
+
}
|
|
180
|
+
|
|
119
181
|
/** Project RoomCommands.rows-compatible data to body-free room status metadata. */
|
|
120
182
|
export function projectRoomStatus(value: unknown): RoomStatusView[] {
|
|
121
183
|
try {
|
|
@@ -139,6 +201,7 @@ export function projectRoomStatus(value: unknown): RoomStatusView[] {
|
|
|
139
201
|
memberCount: memberCount(rawMemberCount),
|
|
140
202
|
remotePeers: peers,
|
|
141
203
|
relays: relayStats(ownData(item, "relays")),
|
|
204
|
+
...projectRoomDiagnostics(item),
|
|
142
205
|
});
|
|
143
206
|
}
|
|
144
207
|
return views;
|
package/src/rooms/store.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
|
+
import type { RoomHealth } from "./health.ts";
|
|
5
6
|
import { realPath } from "../hub/project.ts";
|
|
6
7
|
import { MAX_HOP, USER } from "../hub/envelope.ts";
|
|
7
8
|
import { assertRoomCryptoReady, exportHubIdentityForStorage, generateHubIdentity, importHubIdentity, decodeBase64url, encodeBase64url, sha256, type HubIdentity, type LocalIdentityRecord } from "./crypto.ts";
|
|
@@ -673,6 +674,12 @@ export class RoomStore {
|
|
|
673
674
|
ON CONFLICT(name) DO UPDATE SET epoch=excluded.epoch,digest=excluded.digest,wire=excluded.wire,previous_wire=excluded.previous_wire,previous_digest=excluded.previous_digest,previous_epoch=excluded.previous_epoch,previous_expires=excluded.previous_expires,left=0`)
|
|
674
675
|
.run(name, current.room, broker, decodeRoster(wire).owner, current.epoch, current.digest, wire, prior ? priorWire! : null, prior?.epoch.digest ?? null, prior?.epoch.epoch ?? null, prior?.expiresAt ?? null);
|
|
675
676
|
this.db.query("DELETE FROM pending_joins WHERE name=?").run(name);
|
|
677
|
+
if (previous && !same) {
|
|
678
|
+
// Epoch adoption and the old sealed intents' send fence commit together.
|
|
679
|
+
// The local Bus names the rotation; no new signed receipt reason is introduced.
|
|
680
|
+
this.db.query("UPDATE application_outbox SET state='needs_review',reason='boundary_refused' WHERE room=? AND state IN ('dispatching','accepted') AND disposition IS NULL").run(current.room);
|
|
681
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT t.digest FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id WHERE t.room=? AND a.state='needs_review' AND a.reason='boundary_refused')").run(current.room, current.room);
|
|
682
|
+
}
|
|
676
683
|
return next;
|
|
677
684
|
}
|
|
678
685
|
async install(input: { name: string; broker: string; wire: Uint8Array; expectedDigest: string | null; current?: () => boolean; reactivate?: boolean }): Promise<RoomState> {
|
|
@@ -1102,6 +1109,10 @@ export class RoomStore {
|
|
|
1102
1109
|
const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
|
|
1103
1110
|
return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
|
|
1104
1111
|
}
|
|
1112
|
+
/** Sealed, unresolved intents that a person must see before changing the epoch. */
|
|
1113
|
+
rotationPending(name: string): RoomOutgoing[] {
|
|
1114
|
+
return this.applicationOutbox(name, true).filter(row => row.state === "dispatching" || row.state === "accepted");
|
|
1115
|
+
}
|
|
1105
1116
|
/** Compatibility no-op for retryable rows; only original TTL expiry creates needs_review. */
|
|
1106
1117
|
quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
|
|
1107
1118
|
const state = this.get(name); if (!state) return undefined;
|
|
@@ -1548,9 +1559,27 @@ export class RoomStore {
|
|
|
1548
1559
|
});
|
|
1549
1560
|
}
|
|
1550
1561
|
refused(name: string): RoomRefusal[] {
|
|
1551
|
-
const state = this.get(name); if (!state) return [];
|
|
1562
|
+
this.now(); named(name); const state = this.rooms.get(name); if (!state) return [];
|
|
1552
1563
|
return this.db.query<RoomRefusal, [string]>("SELECT digest,reason,at FROM refusals WHERE room=? ORDER BY rowid").all(state.current.room);
|
|
1553
1564
|
}
|
|
1565
|
+
/** Metadata-only diagnostics. No durable clock write, sealed frame or plaintext read. */
|
|
1566
|
+
health(name: string): RoomHealth {
|
|
1567
|
+
const now = this.now(); named(name); const state = this.rooms.get(name);
|
|
1568
|
+
if (!state) fail("untrusted");
|
|
1569
|
+
const refused: RoomHealth["refused"] = { invalid_presence: 0, wrong_epoch: 0, trust_changed: 0, stale: 0, invalid_frame: 0 };
|
|
1570
|
+
for (const row of this.db.query<{ reason: RoomRefusalReason; count: number }, [string]>("SELECT reason,COUNT(*) AS count FROM refusals WHERE room=? GROUP BY reason").all(state.current.room)) refused[row.reason] = row.count;
|
|
1571
|
+
const pending = this.db.query<{ depth: number; dated: number; oldest: number | null }, [string, number]>(`SELECT COUNT(*) AS depth, COUNT(COALESCE(r.created_at,a.created_at)) AS dated, MIN(COALESCE(r.created_at,a.created_at)) AS oldest
|
|
1572
|
+
FROM outbox o LEFT JOIN application_attempts t ON t.room=o.room AND t.digest=o.digest
|
|
1573
|
+
LEFT JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id
|
|
1574
|
+
LEFT JOIN receipt_outbox r ON r.room=o.room AND r.receipt_digest=o.digest
|
|
1575
|
+
WHERE o.room=? AND (t.digest IS NULL OR a.state IN ('dispatching','accepted'))
|
|
1576
|
+
AND (r.receipt_digest IS NULL OR r.state='dispatching' AND r.expires_at>?)`).get(state.current.room, now)!;
|
|
1577
|
+
const receipt = this.db.query<{ at: number | null }, [string]>(`SELECT MAX(i.received_at) AS at FROM inbox i
|
|
1578
|
+
JOIN application_outbox a ON a.room=i.room AND a.receipt_digest=i.digest WHERE i.room=?`).get(state.current.room)?.at;
|
|
1579
|
+
return { refused, outboxDepth: pending.depth,
|
|
1580
|
+
oldestOutboxAgeMs: pending.depth > 0 && pending.dated === pending.depth && pending.oldest !== null ? now - pending.oldest : null,
|
|
1581
|
+
lastReceiptAgeMs: receipt != null ? now - receipt : null };
|
|
1582
|
+
}
|
|
1554
1583
|
/** Read-only physical-cap hint for a validated host payload; no body is loaded and no replay/clock row is written. */
|
|
1555
1584
|
ordinaryInboxAvailable(name: string, bytes: number): boolean {
|
|
1556
1585
|
this.now(); named(name);
|