@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/service.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/** Host integration for X4. Remote identities carry chat, never local control authority. */
|
|
2
|
-
import type
|
|
3
|
-
import { USER, MAX_HOP, type Envelope, type PeerState } from "../hub/envelope.ts";
|
|
2
|
+
import { ROOM_EPOCH_ROTATED_REASON, ROOM_PROOF_UNAVAILABLE_REASON, type Bus } from "../hub/bus.ts";
|
|
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";
|
|
6
6
|
import { Limiter, PROJECT_LIMITS, type LimitsConfig } from "../hub/limits.ts";
|
|
7
7
|
import type { PiiVerdict } from "../hub/inference.ts";
|
|
8
8
|
import type { Routing } from "../hub/routing.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { RoomCryptoError } from "./crypto.ts";
|
|
10
|
+
import { roomMembers, RoomWireError } from "./wire.ts";
|
|
10
11
|
import { RoomStore, RoomStoreError, RoomApplicationLimitError, type RoomOutgoing } from "./store.ts";
|
|
11
12
|
import { RoomPolicy } from "./policy.ts";
|
|
12
13
|
import { RemotePeer } from "./remote-peer.ts";
|
|
13
14
|
import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } from "./application.ts";
|
|
14
|
-
import { RoomApplicationDeferredError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
|
|
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";
|
|
15
17
|
|
|
16
18
|
export interface RoomInvocationIdentity { room: string; member: string; epoch: string; current: () => boolean }
|
|
17
19
|
export interface RoomInvocationIngress {
|
|
@@ -19,7 +21,7 @@ export interface RoomInvocationIngress {
|
|
|
19
21
|
current: () => boolean; claim: () => boolean;
|
|
20
22
|
}
|
|
21
23
|
export interface RoomChatOptions {
|
|
22
|
-
bus: Bus; cwd: string; now?: () => number;
|
|
24
|
+
bus: Bus; cwd: string; now?: () => number; refreshMs?: number;
|
|
23
25
|
routing: () => Routing; piiPaths: () => readonly string[];
|
|
24
26
|
senderPrivate: (peer: string) => boolean | undefined;
|
|
25
27
|
taskPrivate: (id: number) => boolean | undefined;
|
|
@@ -33,7 +35,7 @@ export interface RoomChatOptions {
|
|
|
33
35
|
notice?: (value: { code: "application_capacity"; room: string; count: number } | { code: "sequence_gap"; room: string; sender: string; count: string }) => void;
|
|
34
36
|
}
|
|
35
37
|
export interface RoomMetrics { since: number; outbound: number | null; inbound: number | null; withheld: number | null }
|
|
36
|
-
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 }
|
|
37
39
|
interface Target { binding: Binding; member: string; peer: string }
|
|
38
40
|
const ACTIVE = new Set(["dispatching", "accepted", "needs_review"]);
|
|
39
41
|
function completedText(reason?: RoomReceiptReason): string {
|
|
@@ -48,6 +50,12 @@ function refusalReason(row: Pick<RoomOutgoing, "reason" | "lateReceipt">): RoomR
|
|
|
48
50
|
const late = row.lateReceipt?.reason;
|
|
49
51
|
return late && late !== "admitted" && late !== "inbox_admitted" ? late : undefined;
|
|
50
52
|
}
|
|
53
|
+
function permanentOutboundProofFailure(error: unknown): boolean {
|
|
54
|
+
if (error instanceof RoomStoreError) return ["invalid", "corrupt", "unsupported", "untrusted", "state_changed"].includes(error.code);
|
|
55
|
+
if (error instanceof RoomCryptoError) return error.code !== "unavailable";
|
|
56
|
+
if (error instanceof RoomWireError) return ["invalid_wire", "untrusted", "wrong_epoch", "authentication_failed", "state_changed"].includes(error.code);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
51
59
|
|
|
52
60
|
export class RoomChat {
|
|
53
61
|
private readonly policy: RoomPolicy;
|
|
@@ -60,6 +68,11 @@ export class RoomChat {
|
|
|
60
68
|
private statusTargets = new Map<string, string[] | null>();
|
|
61
69
|
private readonly remotes = new Map<string, RemotePeer>();
|
|
62
70
|
private readonly outgoing = new Map<string, { binding: Binding; row: RoomOutgoing }>();
|
|
71
|
+
private readonly resuming = new Set<string>();
|
|
72
|
+
private readonly personHeldInvocations = new Map<string, Set<string>>();
|
|
73
|
+
private readonly refreshMs: number;
|
|
74
|
+
private presenceClockHighWater?: number;
|
|
75
|
+
private presenceClockInvalid = false;
|
|
63
76
|
private evidenceCache?: Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>;
|
|
64
77
|
private timer?: ReturnType<typeof setTimeout>;
|
|
65
78
|
private stopped = false;
|
|
@@ -69,25 +82,76 @@ export class RoomChat {
|
|
|
69
82
|
readonly application: RoomLinkApplication;
|
|
70
83
|
constructor(private readonly options: RoomChatOptions) {
|
|
71
84
|
this.policy = new RoomPolicy(options.cwd);
|
|
85
|
+
this.refreshMs = roomRefreshPeriod(options.refreshMs ?? ROOM_REFRESH_MS);
|
|
72
86
|
let since: number | null = null;
|
|
73
|
-
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. */ }
|
|
74
88
|
this.metricsSince = since;
|
|
75
89
|
this.application = {
|
|
76
90
|
members: context => { if (context.current()) this.bind(context.store, context.name, context.link); },
|
|
77
91
|
receive: (context, input) => this.receive(context, input),
|
|
78
|
-
online: async context => { if (context.current()) {
|
|
92
|
+
online: async context => { if (context.current()) {
|
|
93
|
+
this.bind(context.store, context.name, context.link);
|
|
94
|
+
const binding = this.bindings.get(context.name)!; binding.online = true;
|
|
95
|
+
this.refreshTargets(); this.refreshOutgoing(binding); this.queueChanged();
|
|
96
|
+
this.wakeRemotes(binding);
|
|
97
|
+
this.reconcileSignedOutcomes(binding);
|
|
98
|
+
this.reconcileMissingStoreIntents(binding);
|
|
99
|
+
void this.resumeActive(binding);
|
|
100
|
+
} },
|
|
101
|
+
receiptTtlMs: context => { if (!context.current()) throw new Error("room: state_changed"); return this.policy.timing().ttlMs; },
|
|
79
102
|
stored: (context, input) => {
|
|
80
103
|
if (!context.current()) return;
|
|
81
104
|
const row = context.store.markApplicationStored(context.name, input.digest, input.seq);
|
|
82
105
|
if (row) this.outcome(this.bindings.get(context.name)!, row);
|
|
106
|
+
const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding);
|
|
83
107
|
},
|
|
84
|
-
capacityChanged: context => { if (context.current()) this.capacityChanged(); },
|
|
108
|
+
capacityChanged: context => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding); this.capacityChanged(); } },
|
|
85
109
|
gap: (context, input) => {
|
|
86
110
|
if (context.current()) this.options.notice?.({ code: "sequence_gap", room: context.store.get(context.name)!.current.room, sender: input.sender, count: input.count });
|
|
87
111
|
},
|
|
88
112
|
};
|
|
89
113
|
}
|
|
90
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
|
+
}
|
|
152
|
+
private wakeRemotes(binding: Binding): void {
|
|
153
|
+
for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.wake();
|
|
154
|
+
}
|
|
91
155
|
metrics(name: string, room?: string): RoomMetrics | null {
|
|
92
156
|
const binding = this.bindings.get(name);
|
|
93
157
|
if (this.stopped || !binding || this.metricsSince === null || room !== undefined && binding.room !== room) return null;
|
|
@@ -107,16 +171,38 @@ export class RoomChat {
|
|
|
107
171
|
private valid(binding: Binding): boolean {
|
|
108
172
|
return !this.stopped && this.bindings.get(binding.name) === binding && binding.store.get(binding.name)?.left === false;
|
|
109
173
|
}
|
|
174
|
+
private currentOutgoingRow(binding: Binding, expected: RoomOutgoing): () => boolean {
|
|
175
|
+
const digest = binding.digest, room = binding.room;
|
|
176
|
+
return () => {
|
|
177
|
+
try {
|
|
178
|
+
if (!this.valid(binding) || binding.store.get(binding.name)?.current.room !== room || binding.store.get(binding.name)?.current.digest !== digest) return false;
|
|
179
|
+
const row = binding.store.applicationOutbox(binding.name).find(item => item.deliveryId === expected.deliveryId);
|
|
180
|
+
return !!row && row.application === expected.application && row.remotePeer === expected.remotePeer
|
|
181
|
+
&& row.recipient === expected.recipient && row.peer === expected.peer && row.state === expected.state
|
|
182
|
+
&& row.disposition === expected.disposition && row.reason === expected.reason && row.expiresAt === expected.expiresAt
|
|
183
|
+
&& JSON.stringify(row.lateReceipt) === JSON.stringify(expected.lateReceipt);
|
|
184
|
+
} catch { return false; }
|
|
185
|
+
};
|
|
186
|
+
}
|
|
110
187
|
bind(store: RoomStore, name: string, link: RoomLink): void {
|
|
111
188
|
if (this.stopped) return;
|
|
112
189
|
const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
|
|
113
|
-
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);
|
|
114
191
|
if (counted?.room !== room && (counted || this.counts.size < 64)) this.counts.set(name, { room, outbound: 0, inbound: 0, withheld: 0 });
|
|
115
192
|
const saved = this.bindings.get(name);
|
|
116
|
-
const binding = saved?.store === store && saved.link === link && saved.room === room ? saved : {
|
|
117
|
-
|
|
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;
|
|
118
202
|
this.bindings.set(name, binding);
|
|
119
|
-
this.
|
|
203
|
+
this.reconcileDispositions(binding);
|
|
204
|
+
this.refreshInvocationHolds(binding);
|
|
205
|
+
this.refreshOutgoing(binding); this.refreshTargets(); this.reconcileProofUnavailable(binding); this.queueChanged();
|
|
120
206
|
}
|
|
121
207
|
invocationIdentity(id: string, sender?: string): RoomInvocationIdentity | undefined {
|
|
122
208
|
const target = this.targets.get(id), state = target?.binding.store.get(target.binding.name);
|
|
@@ -128,6 +214,14 @@ export class RoomChat {
|
|
|
128
214
|
} };
|
|
129
215
|
}
|
|
130
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
|
+
}
|
|
131
225
|
const candidates = new Map<string, Target[]>();
|
|
132
226
|
for (const binding of this.bindings.values()) {
|
|
133
227
|
const state = binding.store.get(binding.name);
|
|
@@ -177,22 +271,43 @@ export class RoomChat {
|
|
|
177
271
|
const binding = selected.binding;
|
|
178
272
|
if (!current()) throw new Error("room: state_changed");
|
|
179
273
|
const timing = this.policy.timing(), remaining = expiresAt - this.now();
|
|
180
|
-
|
|
274
|
+
const retained = binding.store.applicationOutbox(binding.name, true).find(row => row.deliveryId === deliveryId);
|
|
275
|
+
if (remaining <= 0) throw retained ? new Error("room: expired") : new RoomNotSentError();
|
|
181
276
|
try {
|
|
182
277
|
await binding.link.publishApplication({ v: 1, type: "chat", envelope: env }, target.member, {
|
|
183
278
|
deliveryId, application: env.id, remotePeer: id, recipient: target.member, peer: target.peer,
|
|
184
|
-
ackTimeoutMs:
|
|
279
|
+
ackTimeoutMs: retained?.ackTimeoutMs ?? timing.ackTimeoutMs,
|
|
280
|
+
ttlMs: retained ? retained.expiresAt - retained.createdAt : Math.min(remaining, timing.ttlMs),
|
|
185
281
|
}, current);
|
|
186
282
|
this.count(binding, "outbound", withheld === true);
|
|
187
|
-
}
|
|
283
|
+
}
|
|
188
284
|
finally { this.refreshOutgoing(binding); this.queueChanged(); }
|
|
189
|
-
},
|
|
285
|
+
}, ready: () => this.options.bus.remoteSendAllowed(remote!) && this.targetSendable(id), waiting: () => this.queueChanged(),
|
|
286
|
+
notSent: deliveryId => this.options.bus.requeueRemoteNotSent(remote!, deliveryId),
|
|
287
|
+
queued: deliveryId => {
|
|
288
|
+
const selected = this.targets.get(id);
|
|
289
|
+
if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) return undefined;
|
|
290
|
+
try { return selected.binding.store.applicationOutbox(selected.binding.name).some(row => row.deliveryId === deliveryId); }
|
|
291
|
+
catch { return undefined; }
|
|
292
|
+
}, holdMissing: deliveryId => {
|
|
293
|
+
const selected = this.targets.get(id);
|
|
294
|
+
if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) return false;
|
|
295
|
+
const current = () => {
|
|
296
|
+
if (!this.valid(selected.binding) || this.targets.get(id)?.binding !== selected.binding
|
|
297
|
+
|| this.targets.get(id)?.member !== target.member || this.targets.get(id)?.peer !== target.peer) return false;
|
|
298
|
+
try { return !selected.binding.store.applicationOutbox(selected.binding.name).some(row => row.deliveryId === deliveryId); }
|
|
299
|
+
catch { return false; }
|
|
300
|
+
};
|
|
301
|
+
const held = this.options.bus.holdRemoteMissingIntent(remote!, deliveryId, current, "known-not-sent");
|
|
302
|
+
if (held) remote!.settled(deliveryId);
|
|
303
|
+
return held;
|
|
304
|
+
}, quarantine: (deliveryId, reason) => {
|
|
190
305
|
const selected = this.targets.get(id);
|
|
191
306
|
const cached = [...this.outgoing.values()].filter(value => value.row.deliveryId === deliveryId && value.row.remotePeer === id && value.row.recipient === target.member).map(value => value.binding);
|
|
192
307
|
const bindings = [...new Set([...(selected?.member === target.member && selected.peer === target.peer ? [selected.binding] : []), ...cached])];
|
|
193
308
|
for (const binding of bindings) try {
|
|
194
309
|
if (binding.store.get(binding.name)?.current.room !== room) continue;
|
|
195
|
-
const row = binding.store.
|
|
310
|
+
const row = binding.store.quarantineOutgoingPermanent(binding.name, deliveryId, reason);
|
|
196
311
|
if (row && row.remotePeer === id && row.recipient === target.member) { this.outcome(binding, row); return; }
|
|
197
312
|
} catch { /* A retained old binding may already be closed after an explicit rejoin. */ }
|
|
198
313
|
}, now: () => this.now(),
|
|
@@ -218,9 +333,24 @@ export class RoomChat {
|
|
|
218
333
|
else { ids.push(id); shown++; }
|
|
219
334
|
}
|
|
220
335
|
this.statusTargets = statusTargets;
|
|
336
|
+
// Reserve restored Store intents before publishing a fresh idle presence can
|
|
337
|
+
// make the Bus drain a later queued envelope during asynchronous proof reads.
|
|
221
338
|
for (const [id, remote] of this.remotes) {
|
|
222
339
|
const target = this.targets.get(id);
|
|
223
|
-
|
|
340
|
+
if (!target) continue;
|
|
341
|
+
const queuedIds = new Set(this.options.bus.pendingEnvelopes(id).map(env => env.id));
|
|
342
|
+
for (const { binding, row } of this.outgoing.values()) {
|
|
343
|
+
if (binding === target.binding && row.remotePeer === id && row.recipient === target.member && row.peer === target.peer
|
|
344
|
+
&& (row.state === "dispatching" || row.state === "accepted") && !row.disposition
|
|
345
|
+
&& !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId) && !queuedIds.has(row.application)) {
|
|
346
|
+
remote.retainPending(row.deliveryId);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const [id, remote] of this.remotes) {
|
|
351
|
+
const target = this.targets.get(id);
|
|
352
|
+
const state = target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline";
|
|
353
|
+
remote.presence(state, target?.binding.online === true);
|
|
224
354
|
}
|
|
225
355
|
this.options.roster?.([...this.targets.keys()]);
|
|
226
356
|
}
|
|
@@ -230,24 +360,36 @@ export class RoomChat {
|
|
|
230
360
|
&& this.policy.get(binding.name).includes(peer)
|
|
231
361
|
&& roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
|
|
232
362
|
}
|
|
233
|
-
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 {
|
|
234
364
|
const binding = this.bindings.get(name), state = store.get(name);
|
|
235
365
|
if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
|
|
236
366
|
const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
|
|
237
367
|
if (!member) return;
|
|
368
|
+
if (value.restored && binding.observedMembers.has(value.member)) return;
|
|
238
369
|
for (const peer of member.exposedPeers) binding.states.delete(`${peer}@${member.label}`);
|
|
239
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
|
+
}
|
|
240
376
|
this.refreshTargets();
|
|
377
|
+
this.queueChanged();
|
|
241
378
|
}
|
|
242
379
|
linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
|
|
380
|
+
// stop() fences callbacks as part of ordinary shutdown; it is not a room
|
|
381
|
+
// trust revocation and must leave durable outbound intents resumable.
|
|
382
|
+
if (this.stopped) return;
|
|
243
383
|
const binding = this.bindings.get(name);
|
|
244
384
|
if (!binding || binding.store !== store || binding.link !== link) return;
|
|
245
|
-
|
|
385
|
+
// Application.online promotes readiness after membership and inbox recovery finish.
|
|
386
|
+
// A raw socket-online notification must not start the same recovery twice.
|
|
387
|
+
if (state !== "online") binding.online = false;
|
|
246
388
|
// A transport blip does not revoke the last authenticated signed state.
|
|
247
389
|
// A new roster digest still clears it in bind(), before target reconstruction.
|
|
248
390
|
if (!this.valid(binding)) {
|
|
249
391
|
try { for (const row of binding.store.applicationOutbox(name, true)) if (row.state === "dispatching" || row.state === "accepted") {
|
|
250
|
-
const unknown = binding.store.
|
|
392
|
+
const unknown = binding.store.quarantineOutgoingPermanent(name, row.deliveryId, "boundary_refused"); if (unknown) this.outcome(binding, unknown);
|
|
251
393
|
} } catch { for (const [key, row] of this.outgoing) if (row.binding === binding) this.outgoing.delete(key); }
|
|
252
394
|
}
|
|
253
395
|
this.refreshTargets();
|
|
@@ -262,11 +404,120 @@ export class RoomChat {
|
|
|
262
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))];
|
|
263
405
|
}
|
|
264
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);
|
|
265
410
|
const remote = this.remotes.get(row.remotePeer);
|
|
266
|
-
if (row.
|
|
411
|
+
if (row.state === "needs_review" && !row.disposition) {
|
|
412
|
+
const journalRow = this.options.bus.queueShow(row.deliveryId);
|
|
413
|
+
if (journalRow?.originals.some(isInvocationKind) && !this.options.bus.remoteDisposition(row.deliveryId)) {
|
|
414
|
+
const held = this.personHeldInvocations.get(binding.name) ?? new Set<string>();
|
|
415
|
+
held.add(row.deliveryId); this.personHeldInvocations.set(binding.name, held);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if ((row.state === "completed" || row.state === "failed_safe") && !row.disposition) this.settleStoredOutcome(binding, remote, row);
|
|
419
|
+
else if (row.state === "needs_review" && !row.disposition && remote) {
|
|
420
|
+
if (!this.holdStoreNeedsReview(binding, row, remote)) remote.receipt({ id: row.deliveryId, state: "needs_review", reason: "room acknowledgement expired or outcome uncertain" });
|
|
421
|
+
}
|
|
267
422
|
else if (!row.disposition) remote?.receipt({ id: row.deliveryId, state: row.state === "dispatching" ? "needs_review" : row.state, reason: row.state === "completed" ? `room ${completedText(row.reason)}` : row.state === "failed_safe" ? roomReceiptReasonText(row.reason ?? "invalid_message") : row.state === "accepted" ? "room broker accepted ciphertext" : "room acknowledgement expired or outcome uncertain" });
|
|
268
423
|
this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
|
|
269
424
|
}
|
|
425
|
+
private settleStoredOutcome(binding: Binding, remote: RemotePeer | undefined, row: RoomOutgoing): boolean {
|
|
426
|
+
if ((row.state !== "completed" && row.state !== "failed_safe") || row.disposition) return false;
|
|
427
|
+
this.refreshOutgoing(binding);
|
|
428
|
+
const outcome = row.lateReceipt?.outcome ?? row.state;
|
|
429
|
+
const reason = row.lateReceipt?.reason ?? row.reason ?? (outcome === "completed" ? "admitted" : "boundary_refused");
|
|
430
|
+
const journalRow = this.options.bus.queueShow(row.deliveryId);
|
|
431
|
+
if (journalRow?.state === "needs_review" && journalRow.originals.some(isInvocationKind)) {
|
|
432
|
+
if (!this.options.bus.remoteDisposition(row.deliveryId)) this.options.bus.noteLateRemoteReceipt(row.remotePeer, row.deliveryId, outcome);
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
const target = this.targets.get(row.remotePeer), state = binding.store.get(binding.name);
|
|
436
|
+
const targetCurrent = target?.binding === binding && target.member === row.recipient && target.peer === row.peer;
|
|
437
|
+
const proofCurrent = () => this.valid(binding) && binding.store.get(binding.name)?.current.room === binding.room
|
|
438
|
+
&& binding.store.get(binding.name)?.current.digest === state?.current.digest
|
|
439
|
+
&& binding.store.applicationOutbox(binding.name).some(saved => saved.deliveryId === row.deliveryId
|
|
440
|
+
&& saved.remotePeer === row.remotePeer && saved.recipient === row.recipient && saved.peer === row.peer
|
|
441
|
+
&& saved.state === row.state && !saved.disposition);
|
|
442
|
+
if (!targetCurrent || !remote) {
|
|
443
|
+
const invalidated = () => proofCurrent() && (() => {
|
|
444
|
+
const next = this.targets.get(row.remotePeer);
|
|
445
|
+
return !next || next.binding !== binding || next.member !== row.recipient || next.peer !== row.peer;
|
|
446
|
+
})();
|
|
447
|
+
return this.options.bus.holdInvalidatedRemoteOutcome(row.remotePeer, row.deliveryId, binding.room, row.recipient, invalidated);
|
|
448
|
+
}
|
|
449
|
+
const current = () => this.valid(binding) && target?.binding === binding && target.member === row.recipient
|
|
450
|
+
&& target.peer === row.peer && state?.current.room === binding.room
|
|
451
|
+
&& binding.store.get(binding.name)?.current.digest === state?.current.digest;
|
|
452
|
+
const settled = this.options.bus.settleRemoteReceipt(remote, row.deliveryId, outcome,
|
|
453
|
+
outcome === "completed" ? `room ${completedText(reason)}` : `room ${roomReceiptReasonText(reason)}`, current);
|
|
454
|
+
if (settled) remote.settled(row.deliveryId);
|
|
455
|
+
return settled;
|
|
456
|
+
}
|
|
457
|
+
/** Reconcile a signed store commit that may have preceded a process crash or Bus CAS. */
|
|
458
|
+
private reconcileSignedOutcomes(binding: Binding): void {
|
|
459
|
+
if (!binding.online || !this.valid(binding)) return;
|
|
460
|
+
try {
|
|
461
|
+
for (const row of binding.store.applicationOutbox(binding.name)) {
|
|
462
|
+
if ((row.state === "completed" || row.state === "failed_safe") && !row.disposition) {
|
|
463
|
+
const remote = this.remotes.get(row.remotePeer);
|
|
464
|
+
this.settleStoredOutcome(binding, remote, row);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} catch { /* The next authenticated online edge retries the signed outcome reconciliation. */ }
|
|
468
|
+
}
|
|
469
|
+
/** Hold restart-era Bus rows only after signed Store outcomes have had their reconciliation chance. */
|
|
470
|
+
private reconcileMissingStoreIntents(binding: Binding): void {
|
|
471
|
+
if (!binding.online || !this.valid(binding)) return;
|
|
472
|
+
let saved: RoomOutgoing[];
|
|
473
|
+
try { saved = binding.store.applicationOutbox(binding.name); }
|
|
474
|
+
catch { return; }
|
|
475
|
+
const known = new Set(saved.map(row => row.deliveryId));
|
|
476
|
+
for (const [id, target] of this.targets) {
|
|
477
|
+
if (target.binding !== binding) continue;
|
|
478
|
+
const remote = this.remotes.get(id);
|
|
479
|
+
if (!remote) continue;
|
|
480
|
+
const digest = binding.digest;
|
|
481
|
+
for (const row of this.options.bus.queueList(id, true).slice(0, 200)) {
|
|
482
|
+
if ((row.state !== "dispatching" && row.state !== "accepted") || known.has(row.id)) continue;
|
|
483
|
+
const current = () => this.valid(binding) && binding.store.get(binding.name)?.current.digest === digest
|
|
484
|
+
&& this.targets.get(id)?.binding === binding && this.targets.get(id)?.member === target.member
|
|
485
|
+
&& this.targets.get(id)?.peer === target.peer && binding.store.get(binding.name)?.current.room === binding.room
|
|
486
|
+
&& !binding.store.applicationOutbox(binding.name).some(savedRow => savedRow.deliveryId === row.id);
|
|
487
|
+
this.options.bus.holdRemoteMissingIntent(remote, row.id, current);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
private holdStoreNeedsReview(binding: Binding, row: RoomOutgoing, remote: RemotePeer): boolean {
|
|
492
|
+
const journal = this.options.bus.queueShow(row.deliveryId);
|
|
493
|
+
if (!journal || row.disposition || (journal.state !== "dispatching" && journal.state !== "accepted" && journal.state !== "needs_review")) return false;
|
|
494
|
+
const current = this.currentOutgoingRow(binding, row);
|
|
495
|
+
if (journal.originals.some(isInvocationKind)) return false;
|
|
496
|
+
const held = this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable");
|
|
497
|
+
if (held) remote.settled(row.deliveryId);
|
|
498
|
+
return held;
|
|
499
|
+
}
|
|
500
|
+
/** Reconcile Store TTL/permanent holds and Bus-first proof holds before any resume starts. */
|
|
501
|
+
private reconcileProofUnavailable(binding: Binding): void {
|
|
502
|
+
if (!this.valid(binding)) return;
|
|
503
|
+
let rows: RoomOutgoing[];
|
|
504
|
+
try { rows = binding.store.applicationOutbox(binding.name); } catch { return; }
|
|
505
|
+
this.refreshOutgoing(binding);
|
|
506
|
+
for (const row of rows) {
|
|
507
|
+
if (row.disposition) continue;
|
|
508
|
+
const remote = this.remotes.get(row.remotePeer);
|
|
509
|
+
const journal = this.options.bus.queueShow(row.deliveryId);
|
|
510
|
+
if (!remote || !journal) continue;
|
|
511
|
+
const heldProof = journal.state === "needs_review" && journal.reason === `${ROOM_PROOF_UNAVAILABLE_REASON} ${row.deliveryId}`;
|
|
512
|
+
if (heldProof && (row.state === "dispatching" || row.state === "accepted")) {
|
|
513
|
+
const current = this.currentOutgoingRow(binding, row);
|
|
514
|
+
if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) continue;
|
|
515
|
+
const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current);
|
|
516
|
+
if (quarantined) { this.refreshOutgoing(binding); remote.settled(row.deliveryId); }
|
|
517
|
+
} else if (row.state === "needs_review") this.holdStoreNeedsReview(binding, row, remote);
|
|
518
|
+
}
|
|
519
|
+
this.refreshOutgoing(binding);
|
|
520
|
+
}
|
|
270
521
|
private rate(from: string, member: string, target: string, body: string): boolean {
|
|
271
522
|
const config = this.options.limits?.() ?? PROJECT_LIMITS, key = JSON.stringify(config);
|
|
272
523
|
if (key !== this.limitKey) { this.limitKey = key; this.peerLimiter = new Limiter(config, () => this.now()); }
|
|
@@ -284,6 +535,7 @@ export class RoomChat {
|
|
|
284
535
|
if (!context.current() || this.stopped) throw new Error("room: state_changed");
|
|
285
536
|
const binding = this.bindings.get(context.name);
|
|
286
537
|
if (!binding || binding.store !== context.store || binding.link !== context.link) throw new Error("room: unavailable");
|
|
538
|
+
if (!this.reconcileDispositions(binding)) throw new RoomApplicationDeferredError("journal_unavailable");
|
|
287
539
|
const saved = context.store.applicationReceipt(context.name, input.digest);
|
|
288
540
|
if (saved) { await context.link.publishApplication(saved, input.sender, undefined, context.current); return; }
|
|
289
541
|
if (input.message?.type === "receipt") {
|
|
@@ -363,29 +615,164 @@ export class RoomChat {
|
|
|
363
615
|
if (receipt) await context.link.publishApplication(receipt, input.sender, undefined, context.current);
|
|
364
616
|
this.options.changed?.();
|
|
365
617
|
}
|
|
618
|
+
private refreshInvocationHolds(binding: Binding): void {
|
|
619
|
+
const room = binding.store.get(binding.name);
|
|
620
|
+
if (!room || !this.valid(binding)) return;
|
|
621
|
+
const held = new Set<string>();
|
|
622
|
+
try {
|
|
623
|
+
for (const member of roomMembers(room.current)) {
|
|
624
|
+
if (member.fingerprint === binding.store.identity.public.fingerprint) continue;
|
|
625
|
+
for (const peer of new Set([...member.exposedPeers, USER])) {
|
|
626
|
+
const id = `${peer}@${member.label}`;
|
|
627
|
+
for (const row of this.options.bus.queueList(id, true)) {
|
|
628
|
+
if (row.state === "needs_review" && row.originals.some(isInvocationKind) && !this.options.bus.remoteDisposition(row.id)) held.add(row.id);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
} catch { /* Unknown Bus state never grants a proof resume by itself. */ }
|
|
633
|
+
this.personHeldInvocations.set(binding.name, held);
|
|
634
|
+
}
|
|
366
635
|
queueChanged(): void {
|
|
367
636
|
if (this.timer) clearTimeout(this.timer);
|
|
368
637
|
this.timer = undefined; if (this.stopped || !this.bindings.size) return;
|
|
369
638
|
let next = this.options.bus.nextRemoteExpiry();
|
|
370
|
-
|
|
371
|
-
|
|
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
|
+
}
|
|
649
|
+
for (const { binding, row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
|
|
650
|
+
const due = Math.min(row.expiresAt, binding.online ? row.ackDeadline ?? Infinity : Infinity); next = next === undefined ? due : Math.min(next, due);
|
|
372
651
|
}
|
|
373
652
|
if (next === undefined) return;
|
|
374
653
|
this.timer = setTimeout(() => { this.timer = undefined; try { this.tick(); } catch { this.stop(); } }, Math.max(1, Math.min(2_147_483_647, next - this.now())));
|
|
375
654
|
this.timer.unref?.();
|
|
655
|
+
this.resumeReadyIntents();
|
|
656
|
+
}
|
|
657
|
+
private resumeReadyIntents(): void {
|
|
658
|
+
if (this.stopped) return;
|
|
659
|
+
for (const binding of this.bindings.values()) {
|
|
660
|
+
if (!binding.online || !this.valid(binding)) continue;
|
|
661
|
+
try {
|
|
662
|
+
for (const row of binding.store.applicationOutbox(binding.name, true)) {
|
|
663
|
+
const remote = this.remotes.get(row.remotePeer);
|
|
664
|
+
if (remote?.readyToResume(row.deliveryId)) this.launchResume(binding, row);
|
|
665
|
+
}
|
|
666
|
+
} catch { /* An actual future queue, receipt or online event retries the scan. */ }
|
|
667
|
+
}
|
|
376
668
|
}
|
|
377
669
|
tick(): void {
|
|
378
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();
|
|
379
674
|
this.options.bus.expireRemoteQueues(this.now());
|
|
380
675
|
for (const binding of this.bindings.values()) if (this.valid(binding)) {
|
|
381
676
|
try {
|
|
382
677
|
for (const row of binding.store.expireApplications(binding.name)) this.outcome(binding, row);
|
|
678
|
+
if (binding.online) {
|
|
679
|
+
const due = binding.store.dueApplicationRetries(binding.name);
|
|
680
|
+
this.refreshOutgoing(binding);
|
|
681
|
+
for (const row of due) this.launchResume(binding, row);
|
|
682
|
+
}
|
|
383
683
|
this.reconcileDispositions(binding);
|
|
384
684
|
}
|
|
385
685
|
catch { binding.online = false; for (const [key, entry] of this.outgoing) if (entry.binding === binding) this.outgoing.delete(key); }
|
|
386
686
|
}
|
|
387
687
|
void this.retryPending(); this.queueChanged();
|
|
388
688
|
}
|
|
689
|
+
private async resumeActive(binding: Binding): Promise<void> {
|
|
690
|
+
if (!binding.online || !this.valid(binding)) return;
|
|
691
|
+
try {
|
|
692
|
+
const active = binding.store.applicationOutbox(binding.name, true).filter(row => (row.state === "dispatching" || row.state === "accepted")
|
|
693
|
+
&& !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId));
|
|
694
|
+
for (const row of active) this.remotes.get(row.remotePeer)?.reservePending(row.deliveryId);
|
|
695
|
+
for (const row of active) this.launchResume(binding, row);
|
|
696
|
+
} catch { /* The next authenticated online edge will retry durable rows. */ }
|
|
697
|
+
}
|
|
698
|
+
private launchResume(binding: Binding, row: RoomOutgoing): void {
|
|
699
|
+
const key = `${binding.name}:${row.deliveryId}`;
|
|
700
|
+
if (this.resuming.has(key)) return;
|
|
701
|
+
const remote = this.remotes.get(row.remotePeer);
|
|
702
|
+
if (!remote) return;
|
|
703
|
+
this.resuming.add(key);
|
|
704
|
+
void this.resumeApplication(binding, row, remote).catch(() => { remote.deferResume(row.deliveryId); })
|
|
705
|
+
.finally(() => { this.resuming.delete(key); try { this.queueChanged(); } catch { /* a later real event or ACK deadline retries */ } });
|
|
706
|
+
}
|
|
707
|
+
private storeProofCurrent(binding: Binding, row: RoomOutgoing, remote: RemotePeer, target: Target | undefined, digest: string | undefined): boolean {
|
|
708
|
+
try {
|
|
709
|
+
if (!target || !digest || !this.valid(binding) || binding.store.get(binding.name)?.current.room !== binding.room
|
|
710
|
+
|| binding.store.get(binding.name)?.current.digest !== digest || this.targets.get(row.remotePeer)?.binding !== binding
|
|
711
|
+
|| this.targets.get(row.remotePeer)?.member !== row.recipient || this.targets.get(row.remotePeer)?.peer !== row.peer
|
|
712
|
+
|| !remote.isPendingHead(row.deliveryId) || row.disposition || row.expiresAt <= this.now()) return false;
|
|
713
|
+
return binding.store.applicationOutbox(binding.name, true).some(saved => saved.deliveryId === row.deliveryId
|
|
714
|
+
&& saved.application === row.application && saved.remotePeer === row.remotePeer && saved.recipient === row.recipient
|
|
715
|
+
&& saved.peer === row.peer && (saved.state === "dispatching" || saved.state === "accepted") && !saved.disposition);
|
|
716
|
+
} catch { return false; }
|
|
717
|
+
}
|
|
718
|
+
private holdUnavailableProof(binding: Binding, row: RoomOutgoing, remote: RemotePeer, current: () => boolean): boolean {
|
|
719
|
+
const busRow = this.options.bus.queueShow(row.deliveryId);
|
|
720
|
+
if (busRow?.originals.some(isInvocationKind)) { remote.deferResume(row.deliveryId); return false; }
|
|
721
|
+
if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) { remote.deferResume(row.deliveryId); return false; }
|
|
722
|
+
try { binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current); }
|
|
723
|
+
catch { /* Bus hold is durable; startup reconciliation can finish the Store side. */ }
|
|
724
|
+
remote.settled(row.deliveryId);
|
|
725
|
+
this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
private async resumeApplication(binding: Binding, row: RoomOutgoing, remote: RemotePeer): Promise<void> {
|
|
729
|
+
if (this.personHeldInvocations.get(binding.name)?.has(row.deliveryId)) return;
|
|
730
|
+
if (!remote || !remote.isPendingHead(row.deliveryId)) return;
|
|
731
|
+
const heldRow = this.options.bus.queueShow(row.deliveryId);
|
|
732
|
+
const original = this.options.bus.remoteDeliveryEnvelope(remote, row.deliveryId);
|
|
733
|
+
const proofHoldReason = `${ROOM_PROOF_UNAVAILABLE_REASON} ${row.deliveryId}`;
|
|
734
|
+
if (heldRow?.state === "needs_review" && heldRow.reason === proofHoldReason && original) {
|
|
735
|
+
if (!row.disposition && (row.state === "dispatching" || row.state === "accepted")) {
|
|
736
|
+
const digest = binding.store.get(binding.name)?.current.digest;
|
|
737
|
+
const target = this.targets.get(row.remotePeer);
|
|
738
|
+
const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
|
|
739
|
+
const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", proofCurrent);
|
|
740
|
+
if (quarantined) { remote.settled(row.deliveryId); this.refreshOutgoing(binding); this.queueChanged(); }
|
|
741
|
+
else remote.deferResume(row.deliveryId);
|
|
742
|
+
}
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (!original || !binding.online || !this.valid(binding) || row.disposition || row.expiresAt <= this.now()) { remote.deferResume(row.deliveryId); return; }
|
|
746
|
+
const target = this.targets.get(row.remotePeer), state = binding.store.get(binding.name);
|
|
747
|
+
const digest = state?.current.digest;
|
|
748
|
+
const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
|
|
749
|
+
if (!target || !state || target.binding !== binding || !proofCurrent()) { remote.deferResume(row.deliveryId); return; }
|
|
750
|
+
const ready = () => binding.online && this.options.bus.remoteSendAllowed(remote);
|
|
751
|
+
if (!ready()) { remote.waitForReady(row.deliveryId); return; }
|
|
752
|
+
let readinessLost = false;
|
|
753
|
+
const current = () => {
|
|
754
|
+
const allowed = ready();
|
|
755
|
+
if (!allowed) readinessLost = true;
|
|
756
|
+
return allowed && proofCurrent();
|
|
757
|
+
};
|
|
758
|
+
let saved: RoomMessage | undefined;
|
|
759
|
+
try { saved = await binding.store.outgoingMessage(binding.name, row.application, current); }
|
|
760
|
+
catch (error) {
|
|
761
|
+
if (!proofCurrent()) { remote.deferResume(row.deliveryId); return; }
|
|
762
|
+
if (readinessLost || !ready()) { remote.waitForReady(row.deliveryId); return; }
|
|
763
|
+
if (permanentOutboundProofFailure(error)) { this.holdUnavailableProof(binding, row, remote, proofCurrent); return; }
|
|
764
|
+
remote.deferResume(row.deliveryId); return;
|
|
765
|
+
}
|
|
766
|
+
if (!proofCurrent()) { remote.deferResume(row.deliveryId); return; }
|
|
767
|
+
if (!ready()) { remote.waitForReady(row.deliveryId); return; }
|
|
768
|
+
if (!saved || saved.type !== "chat" || saved.envelope.id !== row.application || saved.envelope.to !== row.peer) {
|
|
769
|
+
this.holdUnavailableProof(binding, row, remote, proofCurrent); return;
|
|
770
|
+
}
|
|
771
|
+
const resumeState = row.state === "accepted" ? "accepted" : "dispatching";
|
|
772
|
+
if (!this.options.bus.resumeRemotePending(remote, row.deliveryId, resumeState, current)) return;
|
|
773
|
+
await remote.resume(original, row.deliveryId, row.expiresAt, saved.envelope);
|
|
774
|
+
this.refreshOutgoing(binding); this.queueChanged();
|
|
775
|
+
}
|
|
389
776
|
/** Global byte capacity can be freed by another room. This is event-coalesced, never polled. */
|
|
390
777
|
private capacityChanged(): void {
|
|
391
778
|
if (this.stopped || this.capacityRefreshPending) return;
|
|
@@ -407,19 +794,36 @@ export class RoomChat {
|
|
|
407
794
|
} catch { /* A completed person disposition must not fail because another room is unavailable. */ }
|
|
408
795
|
await Promise.allSettled(retries); this.capacityChanged();
|
|
409
796
|
}
|
|
410
|
-
private reconcileDispositions(binding: Binding):
|
|
411
|
-
|
|
797
|
+
private reconcileDispositions(binding: Binding): boolean {
|
|
798
|
+
let complete = true;
|
|
799
|
+
const settled: Array<{ peer: string; deliveryId: string }> = [];
|
|
800
|
+
for (const row of binding.store.applicationOutbox(binding.name)) if (!row.disposition) {
|
|
412
801
|
const action = this.options.bus.remoteDisposition(row.deliveryId);
|
|
413
|
-
if (action)
|
|
802
|
+
if (action) try {
|
|
803
|
+
const disposed = binding.store.disposeApplication(binding.name, row.deliveryId, action, true, () => this.valid(binding)
|
|
804
|
+
&& binding.store.get(binding.name)?.current.digest === binding.digest && this.options.bus.remoteDisposition(row.deliveryId) === action);
|
|
805
|
+
if (disposed?.disposition === action) settled.push({ peer: row.remotePeer, deliveryId: row.deliveryId });
|
|
806
|
+
}
|
|
807
|
+
catch { complete = false; }
|
|
414
808
|
}
|
|
415
809
|
this.refreshOutgoing(binding);
|
|
810
|
+
for (const row of settled) this.remotes.get(row.peer)?.settled(row.deliveryId);
|
|
811
|
+
return complete;
|
|
416
812
|
}
|
|
417
813
|
disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
|
|
418
814
|
if (this.options.bus.remoteDisposition(deliveryId) !== action) return;
|
|
419
815
|
for (const binding of this.bindings.values()) {
|
|
420
|
-
const row = binding.store.applicationOutbox(binding.name
|
|
816
|
+
const row = binding.store.applicationOutbox(binding.name).find(row => row.deliveryId === deliveryId && row.remotePeer === peer);
|
|
421
817
|
if (!row) continue;
|
|
422
|
-
binding.
|
|
818
|
+
const room = binding.room, digest = binding.digest;
|
|
819
|
+
const disposed = binding.store.disposeApplication(binding.name, deliveryId, action, true, () => this.valid(binding)
|
|
820
|
+
&& binding.store.get(binding.name)?.current.room === room && binding.store.get(binding.name)?.current.digest === digest
|
|
821
|
+
&& this.options.bus.remoteDisposition(deliveryId) === action);
|
|
822
|
+
if (disposed?.disposition === action) {
|
|
823
|
+
this.refreshOutgoing(binding);
|
|
824
|
+
this.remotes.get(peer)?.settled(deliveryId);
|
|
825
|
+
}
|
|
826
|
+
this.personHeldInvocations.get(binding.name)?.delete(deliveryId);
|
|
423
827
|
this.refreshOutgoing(binding); break;
|
|
424
828
|
}
|
|
425
829
|
this.refreshTargets(); this.queueChanged(); void this.retryPending();
|
|
@@ -440,20 +844,56 @@ export class RoomChat {
|
|
|
440
844
|
const row = this.evidenceCache.get(deliveryId); if (!row || row.peer !== peer) return undefined;
|
|
441
845
|
const { peer: _peer, ...view } = row; return view;
|
|
442
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
|
+
}
|
|
443
875
|
rows(name: string): Record<string, unknown>[] {
|
|
444
876
|
const binding = this.bindings.get(name); if (!binding) return [];
|
|
445
877
|
const incoming = binding.store.applicationStatus(name);
|
|
446
|
-
|
|
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) } : {}) })),
|
|
447
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 } : {}) })),
|
|
448
884
|
{ direction: "inbound", pending: incoming.pending, needsReview: incoming.needsReview, truncated: incoming.truncated }];
|
|
449
885
|
}
|
|
450
|
-
peers(name: string): { id: string; state: PeerState }[] {
|
|
451
|
-
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));
|
|
452
889
|
}
|
|
453
890
|
/** A bounded status list, separate from the explicit detail query above. */
|
|
454
|
-
peersForStatus(name: string): { id: string; state: PeerState }[] | null {
|
|
891
|
+
peersForStatus(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] | null {
|
|
455
892
|
const ids = this.statusTargets.get(name);
|
|
456
|
-
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;
|
|
457
897
|
}
|
|
458
898
|
stop(): void {
|
|
459
899
|
if (this.stopped) return; this.stopped = true;
|