@staix/agent-hub 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +1 -0
- package/docs/agent-notes/tests.md +2 -2
- package/docs/design/bus.md +41 -2
- package/docs/dogfood.md +1 -0
- package/docs/operations.md +54 -6
- package/docs/quickstart.md +6 -0
- package/docs/security.md +24 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +44 -1
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +55 -1
- package/docs/verification/2026-11-06-0.32.0.md +149 -0
- package/docs/verified.json +11 -11
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/cli/console-state.ts +1 -1
- package/src/cli/room.ts +20 -10
- package/src/hub/bus.ts +18 -0
- package/src/hub/daemon.ts +7 -5
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +136 -22
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/service.ts +142 -18
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +10 -0
- package/src/ui/index.html +4 -2
package/src/rooms/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";
|
|
@@ -13,6 +13,7 @@ import { RoomPolicy } from "./policy.ts";
|
|
|
13
13
|
import { RemotePeer } from "./remote-peer.ts";
|
|
14
14
|
import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } 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;
|
|
@@ -34,7 +35,7 @@ export interface RoomChatOptions {
|
|
|
34
35
|
notice?: (value: { code: "application_capacity"; room: string; count: number } | { code: "sequence_gap"; room: string; sender: string; count: string }) => 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); },
|
|
@@ -107,6 +112,43 @@ export class RoomChat {
|
|
|
107
112
|
};
|
|
108
113
|
}
|
|
109
114
|
private now(): number { return this.options.now?.() ?? Date.now(); }
|
|
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
|
+
}
|
|
110
152
|
private wakeRemotes(binding: Binding): void {
|
|
111
153
|
for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.wake();
|
|
112
154
|
}
|
|
@@ -145,11 +187,18 @@ export class RoomChat {
|
|
|
145
187
|
bind(store: RoomStore, name: string, link: RoomLink): void {
|
|
146
188
|
if (this.stopped) return;
|
|
147
189
|
const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
|
|
148
|
-
const room = state.current.room, counted = this.counts.get(name);
|
|
190
|
+
const room = state.current.room, rosterMembers = roomMembers(state.current).map(member => member.fingerprint).sort().join(","), counted = this.counts.get(name);
|
|
149
191
|
if (counted?.room !== room && (counted || this.counts.size < 64)) this.counts.set(name, { room, outbound: 0, inbound: 0, withheld: 0 });
|
|
150
192
|
const saved = this.bindings.get(name);
|
|
151
|
-
const binding = saved?.store === store && saved.link === link && saved.room === room ? saved : {
|
|
152
|
-
|
|
193
|
+
const binding: Binding = saved?.store === store && saved.link === link && saved.room === room ? saved : {
|
|
194
|
+
store, name, room, link, digest, rosterMembers, online: false, states: new Map<string, PeerState>(), observedAt: new Map<string, number>(), observedMembers: new Set<string>(),
|
|
195
|
+
};
|
|
196
|
+
const tracked = link.livenessTracked;
|
|
197
|
+
if (binding.digest !== digest || binding.rosterMembers !== rosterMembers
|
|
198
|
+
|| tracked !== undefined && binding.tracked !== undefined && tracked !== binding.tracked) {
|
|
199
|
+
binding.states.clear(); binding.observedAt.clear(); binding.observedMembers.clear(); binding.online = false; binding.digest = digest; binding.rosterMembers = rosterMembers;
|
|
200
|
+
}
|
|
201
|
+
if (tracked !== undefined) binding.tracked = tracked;
|
|
153
202
|
this.bindings.set(name, binding);
|
|
154
203
|
this.reconcileDispositions(binding);
|
|
155
204
|
this.refreshInvocationHolds(binding);
|
|
@@ -165,6 +214,14 @@ export class RoomChat {
|
|
|
165
214
|
} };
|
|
166
215
|
}
|
|
167
216
|
private refreshTargets(): void {
|
|
217
|
+
const now = this.presenceNow();
|
|
218
|
+
// Consume an expired observation once. Keeping its timestamp makes every
|
|
219
|
+
// later queue callback revisit this branch and can re-reserve settled rows.
|
|
220
|
+
if (now !== undefined) for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) {
|
|
221
|
+
for (const [member, seenAt] of binding.observedAt) {
|
|
222
|
+
if (now >= seenAt && now - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES) binding.observedAt.delete(member);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
168
225
|
const candidates = new Map<string, Target[]>();
|
|
169
226
|
for (const binding of this.bindings.values()) {
|
|
170
227
|
const state = binding.store.get(binding.name);
|
|
@@ -225,7 +282,7 @@ export class RoomChat {
|
|
|
225
282
|
this.count(binding, "outbound", withheld === true);
|
|
226
283
|
}
|
|
227
284
|
finally { this.refreshOutgoing(binding); this.queueChanged(); }
|
|
228
|
-
}, ready: () => this.options.bus.remoteSendAllowed(remote!), waiting: () => this.queueChanged(),
|
|
285
|
+
}, ready: () => this.options.bus.remoteSendAllowed(remote!) && this.targetSendable(id), waiting: () => this.queueChanged(),
|
|
229
286
|
notSent: deliveryId => this.options.bus.requeueRemoteNotSent(remote!, deliveryId),
|
|
230
287
|
queued: deliveryId => {
|
|
231
288
|
const selected = this.targets.get(id);
|
|
@@ -292,7 +349,8 @@ export class RoomChat {
|
|
|
292
349
|
}
|
|
293
350
|
for (const [id, remote] of this.remotes) {
|
|
294
351
|
const target = this.targets.get(id);
|
|
295
|
-
|
|
352
|
+
const state = target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline";
|
|
353
|
+
remote.presence(state, target?.binding.online === true);
|
|
296
354
|
}
|
|
297
355
|
this.options.roster?.([...this.targets.keys()]);
|
|
298
356
|
}
|
|
@@ -302,14 +360,21 @@ export class RoomChat {
|
|
|
302
360
|
&& this.policy.get(binding.name).includes(peer)
|
|
303
361
|
&& roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
|
|
304
362
|
}
|
|
305
|
-
presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[] }): void {
|
|
363
|
+
presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }): void {
|
|
306
364
|
const binding = this.bindings.get(name), state = store.get(name);
|
|
307
365
|
if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
|
|
308
366
|
const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
|
|
309
367
|
if (!member) return;
|
|
368
|
+
if (value.restored && binding.observedMembers.has(value.member)) return;
|
|
310
369
|
for (const peer of member.exposedPeers) binding.states.delete(`${peer}@${member.label}`);
|
|
311
370
|
for (const peer of value.peers) if (member.exposedPeers.includes(peer.id)) binding.states.set(`${peer.id}@${member.label}`, peer.state);
|
|
371
|
+
const now = this.presenceNow();
|
|
372
|
+
if (value.restored !== true) {
|
|
373
|
+
binding.observedMembers.add(member.fingerprint);
|
|
374
|
+
if (now !== undefined) binding.observedAt.set(member.fingerprint, now);
|
|
375
|
+
}
|
|
312
376
|
this.refreshTargets();
|
|
377
|
+
this.queueChanged();
|
|
313
378
|
}
|
|
314
379
|
linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
|
|
315
380
|
// stop() fences callbacks as part of ordinary shutdown; it is not a room
|
|
@@ -339,6 +404,9 @@ export class RoomChat {
|
|
|
339
404
|
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
405
|
}
|
|
341
406
|
private outcome(binding: Binding, row: RoomOutgoing): void {
|
|
407
|
+
// Refresh this snapshot before settlement callbacks synchronously re-enter
|
|
408
|
+
// queueChanged through Bus.onQueues.
|
|
409
|
+
this.refreshOutgoing(binding);
|
|
342
410
|
const remote = this.remotes.get(row.remotePeer);
|
|
343
411
|
if (row.state === "needs_review" && !row.disposition) {
|
|
344
412
|
const journalRow = this.options.bus.queueShow(row.deliveryId);
|
|
@@ -356,6 +424,7 @@ export class RoomChat {
|
|
|
356
424
|
}
|
|
357
425
|
private settleStoredOutcome(binding: Binding, remote: RemotePeer | undefined, row: RoomOutgoing): boolean {
|
|
358
426
|
if ((row.state !== "completed" && row.state !== "failed_safe") || row.disposition) return false;
|
|
427
|
+
this.refreshOutgoing(binding);
|
|
359
428
|
const outcome = row.lateReceipt?.outcome ?? row.state;
|
|
360
429
|
const reason = row.lateReceipt?.reason ?? row.reason ?? (outcome === "completed" ? "admitted" : "boundary_refused");
|
|
361
430
|
const journalRow = this.options.bus.queueShow(row.deliveryId);
|
|
@@ -433,6 +502,7 @@ export class RoomChat {
|
|
|
433
502
|
if (!this.valid(binding)) return;
|
|
434
503
|
let rows: RoomOutgoing[];
|
|
435
504
|
try { rows = binding.store.applicationOutbox(binding.name); } catch { return; }
|
|
505
|
+
this.refreshOutgoing(binding);
|
|
436
506
|
for (const row of rows) {
|
|
437
507
|
if (row.disposition) continue;
|
|
438
508
|
const remote = this.remotes.get(row.remotePeer);
|
|
@@ -443,7 +513,7 @@ export class RoomChat {
|
|
|
443
513
|
const current = this.currentOutgoingRow(binding, row);
|
|
444
514
|
if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) continue;
|
|
445
515
|
const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current);
|
|
446
|
-
if (quarantined) remote.settled(row.deliveryId);
|
|
516
|
+
if (quarantined) { this.refreshOutgoing(binding); remote.settled(row.deliveryId); }
|
|
447
517
|
} else if (row.state === "needs_review") this.holdStoreNeedsReview(binding, row, remote);
|
|
448
518
|
}
|
|
449
519
|
this.refreshOutgoing(binding);
|
|
@@ -566,6 +636,16 @@ export class RoomChat {
|
|
|
566
636
|
if (this.timer) clearTimeout(this.timer);
|
|
567
637
|
this.timer = undefined; if (this.stopped || !this.bindings.size) return;
|
|
568
638
|
let next = this.options.bus.nextRemoteExpiry();
|
|
639
|
+
const presenceNow = this.presenceNow();
|
|
640
|
+
if (presenceNow !== undefined) {
|
|
641
|
+
if ([...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
|
|
642
|
+
presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
|
|
643
|
+
for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) for (const seenAt of binding.observedAt.values()) {
|
|
644
|
+
const staleAt = seenAt + this.refreshMs * ROOM_PRESENCE_MISSES;
|
|
645
|
+
if (staleAt <= presenceNow) continue;
|
|
646
|
+
next = next === undefined ? staleAt : Math.min(next, staleAt);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
569
649
|
for (const { binding, row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
|
|
570
650
|
const due = Math.min(row.expiresAt, binding.online ? row.ackDeadline ?? Infinity : Infinity); next = next === undefined ? due : Math.min(next, due);
|
|
571
651
|
}
|
|
@@ -588,6 +668,9 @@ export class RoomChat {
|
|
|
588
668
|
}
|
|
589
669
|
tick(): void {
|
|
590
670
|
if (this.stopped) return;
|
|
671
|
+
const presenceNow = this.presenceNow();
|
|
672
|
+
if (presenceNow === undefined || [...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
|
|
673
|
+
presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
|
|
591
674
|
this.options.bus.expireRemoteQueues(this.now());
|
|
592
675
|
for (const binding of this.bindings.values()) if (this.valid(binding)) {
|
|
593
676
|
try {
|
|
@@ -713,16 +796,18 @@ export class RoomChat {
|
|
|
713
796
|
}
|
|
714
797
|
private reconcileDispositions(binding: Binding): boolean {
|
|
715
798
|
let complete = true;
|
|
799
|
+
const settled: Array<{ peer: string; deliveryId: string }> = [];
|
|
716
800
|
for (const row of binding.store.applicationOutbox(binding.name)) if (!row.disposition) {
|
|
717
801
|
const action = this.options.bus.remoteDisposition(row.deliveryId);
|
|
718
802
|
if (action) try {
|
|
719
803
|
const disposed = binding.store.disposeApplication(binding.name, row.deliveryId, action, true, () => this.valid(binding)
|
|
720
804
|
&& binding.store.get(binding.name)?.current.digest === binding.digest && this.options.bus.remoteDisposition(row.deliveryId) === action);
|
|
721
|
-
if (disposed?.disposition === action)
|
|
805
|
+
if (disposed?.disposition === action) settled.push({ peer: row.remotePeer, deliveryId: row.deliveryId });
|
|
722
806
|
}
|
|
723
807
|
catch { complete = false; }
|
|
724
808
|
}
|
|
725
809
|
this.refreshOutgoing(binding);
|
|
810
|
+
for (const row of settled) this.remotes.get(row.peer)?.settled(row.deliveryId);
|
|
726
811
|
return complete;
|
|
727
812
|
}
|
|
728
813
|
disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
|
|
@@ -734,7 +819,10 @@ export class RoomChat {
|
|
|
734
819
|
const disposed = binding.store.disposeApplication(binding.name, deliveryId, action, true, () => this.valid(binding)
|
|
735
820
|
&& binding.store.get(binding.name)?.current.room === room && binding.store.get(binding.name)?.current.digest === digest
|
|
736
821
|
&& this.options.bus.remoteDisposition(deliveryId) === action);
|
|
737
|
-
if (disposed?.disposition === action)
|
|
822
|
+
if (disposed?.disposition === action) {
|
|
823
|
+
this.refreshOutgoing(binding);
|
|
824
|
+
this.remotes.get(peer)?.settled(deliveryId);
|
|
825
|
+
}
|
|
738
826
|
this.personHeldInvocations.get(binding.name)?.delete(deliveryId);
|
|
739
827
|
this.refreshOutgoing(binding); break;
|
|
740
828
|
}
|
|
@@ -756,20 +844,56 @@ export class RoomChat {
|
|
|
756
844
|
const row = this.evidenceCache.get(deliveryId); if (!row || row.peer !== peer) return undefined;
|
|
757
845
|
const { peer: _peer, ...view } = row; return view;
|
|
758
846
|
}
|
|
847
|
+
/** Hold only the sealed intents captured by the person's committed epoch change. */
|
|
848
|
+
epochRotated(store: RoomStore, name: string, previousDigest: string, expected: readonly RoomOutgoing[]): boolean {
|
|
849
|
+
const state = store.get(name);
|
|
850
|
+
if (this.stopped || !state || state.left || state.current.digest === previousDigest) return false;
|
|
851
|
+
const binding = this.bindings.get(name);
|
|
852
|
+
if (binding?.store === store && binding.room === state.current.room) {
|
|
853
|
+
// Releasing an old FIFO head must not drain queued work using old presence.
|
|
854
|
+
binding.online = false; binding.states.clear(); binding.observedAt.clear();
|
|
855
|
+
for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.presence("offline", false);
|
|
856
|
+
}
|
|
857
|
+
let complete = true;
|
|
858
|
+
for (const before of expected) {
|
|
859
|
+
const current = () => {
|
|
860
|
+
const room = store.get(name), row = store.applicationOutbox(name).find(row => row.deliveryId === before.deliveryId);
|
|
861
|
+
return !this.stopped && !!room && !room.left && room.current.digest === state.current.digest
|
|
862
|
+
&& !!row && row.application === before.application && row.remotePeer === before.remotePeer && row.recipient === before.recipient
|
|
863
|
+
&& row.state === "needs_review" && row.reason === "boundary_refused" && !row.disposition;
|
|
864
|
+
};
|
|
865
|
+
if (!current()) { complete = false; continue; }
|
|
866
|
+
if (!this.options.bus.holdRemoteEpochRotated(before.remotePeer, before.deliveryId, state.current.room, before.recipient, current)) {
|
|
867
|
+
complete = false; continue;
|
|
868
|
+
}
|
|
869
|
+
const remote = this.remotes.get(before.remotePeer);
|
|
870
|
+
if (remote?.remoteRoom === state.current.room && remote.remoteMember === before.recipient) remote.settled(before.deliveryId);
|
|
871
|
+
}
|
|
872
|
+
this.evidenceCache = undefined;
|
|
873
|
+
return complete;
|
|
874
|
+
}
|
|
759
875
|
rows(name: string): Record<string, unknown>[] {
|
|
760
876
|
const binding = this.bindings.get(name); if (!binding) return [];
|
|
761
877
|
const incoming = binding.store.applicationStatus(name);
|
|
762
|
-
|
|
878
|
+
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>();
|
|
879
|
+
for (const peer of new Set(outgoing.map(row => row.remotePeer))) {
|
|
880
|
+
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);
|
|
881
|
+
}
|
|
882
|
+
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
883
|
...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
884
|
{ direction: "inbound", pending: incoming.pending, needsReview: incoming.needsReview, truncated: incoming.truncated }];
|
|
765
885
|
}
|
|
766
|
-
peers(name: string): { id: string; state: PeerState }[] {
|
|
767
|
-
return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER)
|
|
886
|
+
peers(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] {
|
|
887
|
+
return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER)
|
|
888
|
+
.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
889
|
}
|
|
769
890
|
/** A bounded status list, separate from the explicit detail query above. */
|
|
770
|
-
peersForStatus(name: string): { id: string; state: PeerState }[] | null {
|
|
891
|
+
peersForStatus(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] | null {
|
|
771
892
|
const ids = this.statusTargets.get(name);
|
|
772
|
-
return ids ? ids.map(id =>
|
|
893
|
+
return ids ? ids.map(id => {
|
|
894
|
+
const target = this.targets.get(id)!;
|
|
895
|
+
return { id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) };
|
|
896
|
+
}) : null;
|
|
773
897
|
}
|
|
774
898
|
stop(): void {
|
|
775
899
|
if (this.stopped) return; this.stopped = true;
|
package/src/rooms/status.ts
CHANGED
|
@@ -18,6 +18,8 @@ const PEER_STATES = new Set<PeerState>(["idle", "busy", "paused", "offline"]);
|
|
|
18
18
|
export interface RoomStatusPeer {
|
|
19
19
|
id: string;
|
|
20
20
|
state: PeerState;
|
|
21
|
+
presence?: PeerState | "unknown";
|
|
22
|
+
liveness?: "untracked";
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface RoomRelayStats {
|
|
@@ -95,8 +97,13 @@ function remotePeers(value: unknown, remaining: number): RoomStatusPeer[] | null
|
|
|
95
97
|
if (!plainRecord(item)) return null;
|
|
96
98
|
const id = ownData(item, "id"), state = ownData(item, "state");
|
|
97
99
|
if (typeof id !== "string" || !parseRemotePeerId(id) || typeof state !== "string" || !PEER_STATES.has(state as PeerState) || ids.has(id)) return null;
|
|
100
|
+
const presenceField = ownField(item, "presence"), livenessField = ownField(item, "liveness");
|
|
101
|
+
if (presenceField.present && !Object.hasOwn(presenceField, "value") || livenessField.present && !Object.hasOwn(livenessField, "value")) return null;
|
|
102
|
+
const presence = presenceField.value, liveness = livenessField.value;
|
|
103
|
+
if (presence !== undefined && presence !== "unknown" && (typeof presence !== "string" || !PEER_STATES.has(presence as PeerState))) return null;
|
|
104
|
+
if (liveness !== undefined && liveness !== "untracked") return null;
|
|
98
105
|
ids.add(id);
|
|
99
|
-
result.push({ id, state: state as PeerState });
|
|
106
|
+
result.push({ id, state: state as PeerState, ...(presence !== undefined ? { presence: presence as RoomStatusPeer["presence"] } : {}), ...(liveness === "untracked" ? { liveness } : {}) });
|
|
100
107
|
}
|
|
101
108
|
return result;
|
|
102
109
|
}
|
package/src/rooms/store.ts
CHANGED
|
@@ -673,6 +673,12 @@ export class RoomStore {
|
|
|
673
673
|
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
674
|
.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
675
|
this.db.query("DELETE FROM pending_joins WHERE name=?").run(name);
|
|
676
|
+
if (previous && !same) {
|
|
677
|
+
// Epoch adoption and the old sealed intents' send fence commit together.
|
|
678
|
+
// The local Bus names the rotation; no new signed receipt reason is introduced.
|
|
679
|
+
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);
|
|
680
|
+
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);
|
|
681
|
+
}
|
|
676
682
|
return next;
|
|
677
683
|
}
|
|
678
684
|
async install(input: { name: string; broker: string; wire: Uint8Array; expectedDigest: string | null; current?: () => boolean; reactivate?: boolean }): Promise<RoomState> {
|
|
@@ -1102,6 +1108,10 @@ export class RoomStore {
|
|
|
1102
1108
|
const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
|
|
1103
1109
|
return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
|
|
1104
1110
|
}
|
|
1111
|
+
/** Sealed, unresolved intents that a person must see before changing the epoch. */
|
|
1112
|
+
rotationPending(name: string): RoomOutgoing[] {
|
|
1113
|
+
return this.applicationOutbox(name, true).filter(row => row.state === "dispatching" || row.state === "accepted");
|
|
1114
|
+
}
|
|
1105
1115
|
/** Compatibility no-op for retryable rows; only original TTL expiry creates needs_review. */
|
|
1106
1116
|
quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
|
|
1107
1117
|
const state = this.get(name); if (!state) return undefined;
|
package/src/ui/index.html
CHANGED
|
@@ -1674,8 +1674,10 @@ function renderRooms(rooms) {
|
|
|
1674
1674
|
const peers = el('div', undefined, 'peer-summary');
|
|
1675
1675
|
for (const peer of room.remotePeers) {
|
|
1676
1676
|
const id = roomMetadataText(peer.id, /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/, 65);
|
|
1677
|
-
const
|
|
1678
|
-
|
|
1677
|
+
const observed = peer.presence ?? peer.state;
|
|
1678
|
+
const state = ['idle', 'busy', 'paused', 'offline', 'unknown'].includes(observed) ? observed : 'unknown';
|
|
1679
|
+
const note = peer.liveness === 'untracked' ? '; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness' : '';
|
|
1680
|
+
peers.append(el('span', id + ': ' + state + note, 'meta'));
|
|
1679
1681
|
}
|
|
1680
1682
|
row.append(peers);
|
|
1681
1683
|
}
|