@staix/agent-hub 0.29.0 → 0.31.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 +51 -0
- package/README.md +15 -7
- package/docs/agent-notes/README.md +13 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +10 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +10 -4
- package/docs/agent-notes/tests.md +8 -4
- package/docs/design/adapters.md +108 -0
- package/docs/design/agent-hub-architecture.md +58 -0
- package/docs/design/bus.md +147 -1
- package/docs/design/task-board.md +100 -0
- package/docs/dogfood.md +29 -8
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +410 -86
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +25 -5
- package/docs/sdlc.md +1 -1
- package/docs/security.md +182 -62
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- 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 +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -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/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- 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 +4565 -3617
- 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 +13 -13
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +82 -23
- package/src/adapters/pi.ts +140 -16
- package/src/adapters/registry-types.ts +16 -0
- package/src/adapters/registry.ts +4 -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/autonomy.ts +66 -1
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +82 -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 +10 -5
- package/src/cli/identity.ts +21 -4
- package/src/cli/main.ts +167 -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/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/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +396 -20
- package/src/hub/bus.ts +497 -57
- 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/conductor.ts +4 -3
- package/src/hub/config-apply.ts +8 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1795 -142
- 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/dogfood-read.ts +62 -9
- 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 +66 -11
- package/src/hub/manager.ts +67 -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/peer-doing.ts +33 -3
- package/src/hub/peers.ts +32 -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/settings.ts +3 -0
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +607 -71
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/pi/extension.ts +43 -3
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +271 -17
- package/src/ui/needs.ts +10 -4
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +219 -28
- 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,18 @@
|
|
|
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_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";
|
|
15
16
|
|
|
16
17
|
export interface RoomInvocationIdentity { room: string; member: string; epoch: string; current: () => boolean }
|
|
17
18
|
export interface RoomInvocationIngress {
|
|
@@ -48,6 +49,12 @@ function refusalReason(row: Pick<RoomOutgoing, "reason" | "lateReceipt">): RoomR
|
|
|
48
49
|
const late = row.lateReceipt?.reason;
|
|
49
50
|
return late && late !== "admitted" && late !== "inbox_admitted" ? late : undefined;
|
|
50
51
|
}
|
|
52
|
+
function permanentOutboundProofFailure(error: unknown): boolean {
|
|
53
|
+
if (error instanceof RoomStoreError) return ["invalid", "corrupt", "unsupported", "untrusted", "state_changed"].includes(error.code);
|
|
54
|
+
if (error instanceof RoomCryptoError) return error.code !== "unavailable";
|
|
55
|
+
if (error instanceof RoomWireError) return ["invalid_wire", "untrusted", "wrong_epoch", "authentication_failed", "state_changed"].includes(error.code);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
51
58
|
|
|
52
59
|
export class RoomChat {
|
|
53
60
|
private readonly policy: RoomPolicy;
|
|
@@ -60,6 +67,8 @@ export class RoomChat {
|
|
|
60
67
|
private statusTargets = new Map<string, string[] | null>();
|
|
61
68
|
private readonly remotes = new Map<string, RemotePeer>();
|
|
62
69
|
private readonly outgoing = new Map<string, { binding: Binding; row: RoomOutgoing }>();
|
|
70
|
+
private readonly resuming = new Set<string>();
|
|
71
|
+
private readonly personHeldInvocations = new Map<string, Set<string>>();
|
|
63
72
|
private evidenceCache?: Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>;
|
|
64
73
|
private timer?: ReturnType<typeof setTimeout>;
|
|
65
74
|
private stopped = false;
|
|
@@ -75,19 +84,32 @@ export class RoomChat {
|
|
|
75
84
|
this.application = {
|
|
76
85
|
members: context => { if (context.current()) this.bind(context.store, context.name, context.link); },
|
|
77
86
|
receive: (context, input) => this.receive(context, input),
|
|
78
|
-
online: async context => { if (context.current()) {
|
|
87
|
+
online: async context => { if (context.current()) {
|
|
88
|
+
this.bind(context.store, context.name, context.link);
|
|
89
|
+
const binding = this.bindings.get(context.name)!; binding.online = true;
|
|
90
|
+
this.refreshTargets(); this.refreshOutgoing(binding); this.queueChanged();
|
|
91
|
+
this.wakeRemotes(binding);
|
|
92
|
+
this.reconcileSignedOutcomes(binding);
|
|
93
|
+
this.reconcileMissingStoreIntents(binding);
|
|
94
|
+
void this.resumeActive(binding);
|
|
95
|
+
} },
|
|
96
|
+
receiptTtlMs: context => { if (!context.current()) throw new Error("room: state_changed"); return this.policy.timing().ttlMs; },
|
|
79
97
|
stored: (context, input) => {
|
|
80
98
|
if (!context.current()) return;
|
|
81
99
|
const row = context.store.markApplicationStored(context.name, input.digest, input.seq);
|
|
82
100
|
if (row) this.outcome(this.bindings.get(context.name)!, row);
|
|
101
|
+
const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding);
|
|
83
102
|
},
|
|
84
|
-
capacityChanged: context => { if (context.current()) this.capacityChanged(); },
|
|
103
|
+
capacityChanged: context => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding); this.capacityChanged(); } },
|
|
85
104
|
gap: (context, input) => {
|
|
86
105
|
if (context.current()) this.options.notice?.({ code: "sequence_gap", room: context.store.get(context.name)!.current.room, sender: input.sender, count: input.count });
|
|
87
106
|
},
|
|
88
107
|
};
|
|
89
108
|
}
|
|
90
109
|
private now(): number { return this.options.now?.() ?? Date.now(); }
|
|
110
|
+
private wakeRemotes(binding: Binding): void {
|
|
111
|
+
for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.wake();
|
|
112
|
+
}
|
|
91
113
|
metrics(name: string, room?: string): RoomMetrics | null {
|
|
92
114
|
const binding = this.bindings.get(name);
|
|
93
115
|
if (this.stopped || !binding || this.metricsSince === null || room !== undefined && binding.room !== room) return null;
|
|
@@ -107,6 +129,19 @@ export class RoomChat {
|
|
|
107
129
|
private valid(binding: Binding): boolean {
|
|
108
130
|
return !this.stopped && this.bindings.get(binding.name) === binding && binding.store.get(binding.name)?.left === false;
|
|
109
131
|
}
|
|
132
|
+
private currentOutgoingRow(binding: Binding, expected: RoomOutgoing): () => boolean {
|
|
133
|
+
const digest = binding.digest, room = binding.room;
|
|
134
|
+
return () => {
|
|
135
|
+
try {
|
|
136
|
+
if (!this.valid(binding) || binding.store.get(binding.name)?.current.room !== room || binding.store.get(binding.name)?.current.digest !== digest) return false;
|
|
137
|
+
const row = binding.store.applicationOutbox(binding.name).find(item => item.deliveryId === expected.deliveryId);
|
|
138
|
+
return !!row && row.application === expected.application && row.remotePeer === expected.remotePeer
|
|
139
|
+
&& row.recipient === expected.recipient && row.peer === expected.peer && row.state === expected.state
|
|
140
|
+
&& row.disposition === expected.disposition && row.reason === expected.reason && row.expiresAt === expected.expiresAt
|
|
141
|
+
&& JSON.stringify(row.lateReceipt) === JSON.stringify(expected.lateReceipt);
|
|
142
|
+
} catch { return false; }
|
|
143
|
+
};
|
|
144
|
+
}
|
|
110
145
|
bind(store: RoomStore, name: string, link: RoomLink): void {
|
|
111
146
|
if (this.stopped) return;
|
|
112
147
|
const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
|
|
@@ -116,7 +151,9 @@ export class RoomChat {
|
|
|
116
151
|
const binding = saved?.store === store && saved.link === link && saved.room === room ? saved : { store, name, room, link, digest, online: false, states: new Map<string, PeerState>() };
|
|
117
152
|
if (binding.digest !== digest) { binding.states.clear(); binding.online = false; binding.digest = digest; }
|
|
118
153
|
this.bindings.set(name, binding);
|
|
119
|
-
this.
|
|
154
|
+
this.reconcileDispositions(binding);
|
|
155
|
+
this.refreshInvocationHolds(binding);
|
|
156
|
+
this.refreshOutgoing(binding); this.refreshTargets(); this.reconcileProofUnavailable(binding); this.queueChanged();
|
|
120
157
|
}
|
|
121
158
|
invocationIdentity(id: string, sender?: string): RoomInvocationIdentity | undefined {
|
|
122
159
|
const target = this.targets.get(id), state = target?.binding.store.get(target.binding.name);
|
|
@@ -177,22 +214,43 @@ export class RoomChat {
|
|
|
177
214
|
const binding = selected.binding;
|
|
178
215
|
if (!current()) throw new Error("room: state_changed");
|
|
179
216
|
const timing = this.policy.timing(), remaining = expiresAt - this.now();
|
|
180
|
-
|
|
217
|
+
const retained = binding.store.applicationOutbox(binding.name, true).find(row => row.deliveryId === deliveryId);
|
|
218
|
+
if (remaining <= 0) throw retained ? new Error("room: expired") : new RoomNotSentError();
|
|
181
219
|
try {
|
|
182
220
|
await binding.link.publishApplication({ v: 1, type: "chat", envelope: env }, target.member, {
|
|
183
221
|
deliveryId, application: env.id, remotePeer: id, recipient: target.member, peer: target.peer,
|
|
184
|
-
ackTimeoutMs:
|
|
222
|
+
ackTimeoutMs: retained?.ackTimeoutMs ?? timing.ackTimeoutMs,
|
|
223
|
+
ttlMs: retained ? retained.expiresAt - retained.createdAt : Math.min(remaining, timing.ttlMs),
|
|
185
224
|
}, current);
|
|
186
225
|
this.count(binding, "outbound", withheld === true);
|
|
187
|
-
}
|
|
226
|
+
}
|
|
188
227
|
finally { this.refreshOutgoing(binding); this.queueChanged(); }
|
|
189
|
-
},
|
|
228
|
+
}, ready: () => this.options.bus.remoteSendAllowed(remote!), waiting: () => this.queueChanged(),
|
|
229
|
+
notSent: deliveryId => this.options.bus.requeueRemoteNotSent(remote!, deliveryId),
|
|
230
|
+
queued: deliveryId => {
|
|
231
|
+
const selected = this.targets.get(id);
|
|
232
|
+
if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) return undefined;
|
|
233
|
+
try { return selected.binding.store.applicationOutbox(selected.binding.name).some(row => row.deliveryId === deliveryId); }
|
|
234
|
+
catch { return undefined; }
|
|
235
|
+
}, holdMissing: deliveryId => {
|
|
236
|
+
const selected = this.targets.get(id);
|
|
237
|
+
if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) return false;
|
|
238
|
+
const current = () => {
|
|
239
|
+
if (!this.valid(selected.binding) || this.targets.get(id)?.binding !== selected.binding
|
|
240
|
+
|| this.targets.get(id)?.member !== target.member || this.targets.get(id)?.peer !== target.peer) return false;
|
|
241
|
+
try { return !selected.binding.store.applicationOutbox(selected.binding.name).some(row => row.deliveryId === deliveryId); }
|
|
242
|
+
catch { return false; }
|
|
243
|
+
};
|
|
244
|
+
const held = this.options.bus.holdRemoteMissingIntent(remote!, deliveryId, current, "known-not-sent");
|
|
245
|
+
if (held) remote!.settled(deliveryId);
|
|
246
|
+
return held;
|
|
247
|
+
}, quarantine: (deliveryId, reason) => {
|
|
190
248
|
const selected = this.targets.get(id);
|
|
191
249
|
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
250
|
const bindings = [...new Set([...(selected?.member === target.member && selected.peer === target.peer ? [selected.binding] : []), ...cached])];
|
|
193
251
|
for (const binding of bindings) try {
|
|
194
252
|
if (binding.store.get(binding.name)?.current.room !== room) continue;
|
|
195
|
-
const row = binding.store.
|
|
253
|
+
const row = binding.store.quarantineOutgoingPermanent(binding.name, deliveryId, reason);
|
|
196
254
|
if (row && row.remotePeer === id && row.recipient === target.member) { this.outcome(binding, row); return; }
|
|
197
255
|
} catch { /* A retained old binding may already be closed after an explicit rejoin. */ }
|
|
198
256
|
}, now: () => this.now(),
|
|
@@ -218,6 +276,20 @@ export class RoomChat {
|
|
|
218
276
|
else { ids.push(id); shown++; }
|
|
219
277
|
}
|
|
220
278
|
this.statusTargets = statusTargets;
|
|
279
|
+
// Reserve restored Store intents before publishing a fresh idle presence can
|
|
280
|
+
// make the Bus drain a later queued envelope during asynchronous proof reads.
|
|
281
|
+
for (const [id, remote] of this.remotes) {
|
|
282
|
+
const target = this.targets.get(id);
|
|
283
|
+
if (!target) continue;
|
|
284
|
+
const queuedIds = new Set(this.options.bus.pendingEnvelopes(id).map(env => env.id));
|
|
285
|
+
for (const { binding, row } of this.outgoing.values()) {
|
|
286
|
+
if (binding === target.binding && row.remotePeer === id && row.recipient === target.member && row.peer === target.peer
|
|
287
|
+
&& (row.state === "dispatching" || row.state === "accepted") && !row.disposition
|
|
288
|
+
&& !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId) && !queuedIds.has(row.application)) {
|
|
289
|
+
remote.retainPending(row.deliveryId);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
221
293
|
for (const [id, remote] of this.remotes) {
|
|
222
294
|
const target = this.targets.get(id);
|
|
223
295
|
remote.presence(target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline", target?.binding.online === true);
|
|
@@ -240,14 +312,19 @@ export class RoomChat {
|
|
|
240
312
|
this.refreshTargets();
|
|
241
313
|
}
|
|
242
314
|
linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
|
|
315
|
+
// stop() fences callbacks as part of ordinary shutdown; it is not a room
|
|
316
|
+
// trust revocation and must leave durable outbound intents resumable.
|
|
317
|
+
if (this.stopped) return;
|
|
243
318
|
const binding = this.bindings.get(name);
|
|
244
319
|
if (!binding || binding.store !== store || binding.link !== link) return;
|
|
245
|
-
|
|
320
|
+
// Application.online promotes readiness after membership and inbox recovery finish.
|
|
321
|
+
// A raw socket-online notification must not start the same recovery twice.
|
|
322
|
+
if (state !== "online") binding.online = false;
|
|
246
323
|
// A transport blip does not revoke the last authenticated signed state.
|
|
247
324
|
// A new roster digest still clears it in bind(), before target reconstruction.
|
|
248
325
|
if (!this.valid(binding)) {
|
|
249
326
|
try { for (const row of binding.store.applicationOutbox(name, true)) if (row.state === "dispatching" || row.state === "accepted") {
|
|
250
|
-
const unknown = binding.store.
|
|
327
|
+
const unknown = binding.store.quarantineOutgoingPermanent(name, row.deliveryId, "boundary_refused"); if (unknown) this.outcome(binding, unknown);
|
|
251
328
|
} } catch { for (const [key, row] of this.outgoing) if (row.binding === binding) this.outgoing.delete(key); }
|
|
252
329
|
}
|
|
253
330
|
this.refreshTargets();
|
|
@@ -263,10 +340,114 @@ export class RoomChat {
|
|
|
263
340
|
}
|
|
264
341
|
private outcome(binding: Binding, row: RoomOutgoing): void {
|
|
265
342
|
const remote = this.remotes.get(row.remotePeer);
|
|
266
|
-
if (row.
|
|
343
|
+
if (row.state === "needs_review" && !row.disposition) {
|
|
344
|
+
const journalRow = this.options.bus.queueShow(row.deliveryId);
|
|
345
|
+
if (journalRow?.originals.some(isInvocationKind) && !this.options.bus.remoteDisposition(row.deliveryId)) {
|
|
346
|
+
const held = this.personHeldInvocations.get(binding.name) ?? new Set<string>();
|
|
347
|
+
held.add(row.deliveryId); this.personHeldInvocations.set(binding.name, held);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if ((row.state === "completed" || row.state === "failed_safe") && !row.disposition) this.settleStoredOutcome(binding, remote, row);
|
|
351
|
+
else if (row.state === "needs_review" && !row.disposition && remote) {
|
|
352
|
+
if (!this.holdStoreNeedsReview(binding, row, remote)) remote.receipt({ id: row.deliveryId, state: "needs_review", reason: "room acknowledgement expired or outcome uncertain" });
|
|
353
|
+
}
|
|
267
354
|
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
355
|
this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
|
|
269
356
|
}
|
|
357
|
+
private settleStoredOutcome(binding: Binding, remote: RemotePeer | undefined, row: RoomOutgoing): boolean {
|
|
358
|
+
if ((row.state !== "completed" && row.state !== "failed_safe") || row.disposition) return false;
|
|
359
|
+
const outcome = row.lateReceipt?.outcome ?? row.state;
|
|
360
|
+
const reason = row.lateReceipt?.reason ?? row.reason ?? (outcome === "completed" ? "admitted" : "boundary_refused");
|
|
361
|
+
const journalRow = this.options.bus.queueShow(row.deliveryId);
|
|
362
|
+
if (journalRow?.state === "needs_review" && journalRow.originals.some(isInvocationKind)) {
|
|
363
|
+
if (!this.options.bus.remoteDisposition(row.deliveryId)) this.options.bus.noteLateRemoteReceipt(row.remotePeer, row.deliveryId, outcome);
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
const target = this.targets.get(row.remotePeer), state = binding.store.get(binding.name);
|
|
367
|
+
const targetCurrent = target?.binding === binding && target.member === row.recipient && target.peer === row.peer;
|
|
368
|
+
const proofCurrent = () => this.valid(binding) && binding.store.get(binding.name)?.current.room === binding.room
|
|
369
|
+
&& binding.store.get(binding.name)?.current.digest === state?.current.digest
|
|
370
|
+
&& binding.store.applicationOutbox(binding.name).some(saved => saved.deliveryId === row.deliveryId
|
|
371
|
+
&& saved.remotePeer === row.remotePeer && saved.recipient === row.recipient && saved.peer === row.peer
|
|
372
|
+
&& saved.state === row.state && !saved.disposition);
|
|
373
|
+
if (!targetCurrent || !remote) {
|
|
374
|
+
const invalidated = () => proofCurrent() && (() => {
|
|
375
|
+
const next = this.targets.get(row.remotePeer);
|
|
376
|
+
return !next || next.binding !== binding || next.member !== row.recipient || next.peer !== row.peer;
|
|
377
|
+
})();
|
|
378
|
+
return this.options.bus.holdInvalidatedRemoteOutcome(row.remotePeer, row.deliveryId, binding.room, row.recipient, invalidated);
|
|
379
|
+
}
|
|
380
|
+
const current = () => this.valid(binding) && target?.binding === binding && target.member === row.recipient
|
|
381
|
+
&& target.peer === row.peer && state?.current.room === binding.room
|
|
382
|
+
&& binding.store.get(binding.name)?.current.digest === state?.current.digest;
|
|
383
|
+
const settled = this.options.bus.settleRemoteReceipt(remote, row.deliveryId, outcome,
|
|
384
|
+
outcome === "completed" ? `room ${completedText(reason)}` : `room ${roomReceiptReasonText(reason)}`, current);
|
|
385
|
+
if (settled) remote.settled(row.deliveryId);
|
|
386
|
+
return settled;
|
|
387
|
+
}
|
|
388
|
+
/** Reconcile a signed store commit that may have preceded a process crash or Bus CAS. */
|
|
389
|
+
private reconcileSignedOutcomes(binding: Binding): void {
|
|
390
|
+
if (!binding.online || !this.valid(binding)) return;
|
|
391
|
+
try {
|
|
392
|
+
for (const row of binding.store.applicationOutbox(binding.name)) {
|
|
393
|
+
if ((row.state === "completed" || row.state === "failed_safe") && !row.disposition) {
|
|
394
|
+
const remote = this.remotes.get(row.remotePeer);
|
|
395
|
+
this.settleStoredOutcome(binding, remote, row);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} catch { /* The next authenticated online edge retries the signed outcome reconciliation. */ }
|
|
399
|
+
}
|
|
400
|
+
/** Hold restart-era Bus rows only after signed Store outcomes have had their reconciliation chance. */
|
|
401
|
+
private reconcileMissingStoreIntents(binding: Binding): void {
|
|
402
|
+
if (!binding.online || !this.valid(binding)) return;
|
|
403
|
+
let saved: RoomOutgoing[];
|
|
404
|
+
try { saved = binding.store.applicationOutbox(binding.name); }
|
|
405
|
+
catch { return; }
|
|
406
|
+
const known = new Set(saved.map(row => row.deliveryId));
|
|
407
|
+
for (const [id, target] of this.targets) {
|
|
408
|
+
if (target.binding !== binding) continue;
|
|
409
|
+
const remote = this.remotes.get(id);
|
|
410
|
+
if (!remote) continue;
|
|
411
|
+
const digest = binding.digest;
|
|
412
|
+
for (const row of this.options.bus.queueList(id, true).slice(0, 200)) {
|
|
413
|
+
if ((row.state !== "dispatching" && row.state !== "accepted") || known.has(row.id)) continue;
|
|
414
|
+
const current = () => this.valid(binding) && binding.store.get(binding.name)?.current.digest === digest
|
|
415
|
+
&& this.targets.get(id)?.binding === binding && this.targets.get(id)?.member === target.member
|
|
416
|
+
&& this.targets.get(id)?.peer === target.peer && binding.store.get(binding.name)?.current.room === binding.room
|
|
417
|
+
&& !binding.store.applicationOutbox(binding.name).some(savedRow => savedRow.deliveryId === row.id);
|
|
418
|
+
this.options.bus.holdRemoteMissingIntent(remote, row.id, current);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
private holdStoreNeedsReview(binding: Binding, row: RoomOutgoing, remote: RemotePeer): boolean {
|
|
423
|
+
const journal = this.options.bus.queueShow(row.deliveryId);
|
|
424
|
+
if (!journal || row.disposition || (journal.state !== "dispatching" && journal.state !== "accepted" && journal.state !== "needs_review")) return false;
|
|
425
|
+
const current = this.currentOutgoingRow(binding, row);
|
|
426
|
+
if (journal.originals.some(isInvocationKind)) return false;
|
|
427
|
+
const held = this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable");
|
|
428
|
+
if (held) remote.settled(row.deliveryId);
|
|
429
|
+
return held;
|
|
430
|
+
}
|
|
431
|
+
/** Reconcile Store TTL/permanent holds and Bus-first proof holds before any resume starts. */
|
|
432
|
+
private reconcileProofUnavailable(binding: Binding): void {
|
|
433
|
+
if (!this.valid(binding)) return;
|
|
434
|
+
let rows: RoomOutgoing[];
|
|
435
|
+
try { rows = binding.store.applicationOutbox(binding.name); } catch { return; }
|
|
436
|
+
for (const row of rows) {
|
|
437
|
+
if (row.disposition) continue;
|
|
438
|
+
const remote = this.remotes.get(row.remotePeer);
|
|
439
|
+
const journal = this.options.bus.queueShow(row.deliveryId);
|
|
440
|
+
if (!remote || !journal) continue;
|
|
441
|
+
const heldProof = journal.state === "needs_review" && journal.reason === `${ROOM_PROOF_UNAVAILABLE_REASON} ${row.deliveryId}`;
|
|
442
|
+
if (heldProof && (row.state === "dispatching" || row.state === "accepted")) {
|
|
443
|
+
const current = this.currentOutgoingRow(binding, row);
|
|
444
|
+
if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) continue;
|
|
445
|
+
const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current);
|
|
446
|
+
if (quarantined) remote.settled(row.deliveryId);
|
|
447
|
+
} else if (row.state === "needs_review") this.holdStoreNeedsReview(binding, row, remote);
|
|
448
|
+
}
|
|
449
|
+
this.refreshOutgoing(binding);
|
|
450
|
+
}
|
|
270
451
|
private rate(from: string, member: string, target: string, body: string): boolean {
|
|
271
452
|
const config = this.options.limits?.() ?? PROJECT_LIMITS, key = JSON.stringify(config);
|
|
272
453
|
if (key !== this.limitKey) { this.limitKey = key; this.peerLimiter = new Limiter(config, () => this.now()); }
|
|
@@ -284,6 +465,7 @@ export class RoomChat {
|
|
|
284
465
|
if (!context.current() || this.stopped) throw new Error("room: state_changed");
|
|
285
466
|
const binding = this.bindings.get(context.name);
|
|
286
467
|
if (!binding || binding.store !== context.store || binding.link !== context.link) throw new Error("room: unavailable");
|
|
468
|
+
if (!this.reconcileDispositions(binding)) throw new RoomApplicationDeferredError("journal_unavailable");
|
|
287
469
|
const saved = context.store.applicationReceipt(context.name, input.digest);
|
|
288
470
|
if (saved) { await context.link.publishApplication(saved, input.sender, undefined, context.current); return; }
|
|
289
471
|
if (input.message?.type === "receipt") {
|
|
@@ -363,16 +545,46 @@ export class RoomChat {
|
|
|
363
545
|
if (receipt) await context.link.publishApplication(receipt, input.sender, undefined, context.current);
|
|
364
546
|
this.options.changed?.();
|
|
365
547
|
}
|
|
548
|
+
private refreshInvocationHolds(binding: Binding): void {
|
|
549
|
+
const room = binding.store.get(binding.name);
|
|
550
|
+
if (!room || !this.valid(binding)) return;
|
|
551
|
+
const held = new Set<string>();
|
|
552
|
+
try {
|
|
553
|
+
for (const member of roomMembers(room.current)) {
|
|
554
|
+
if (member.fingerprint === binding.store.identity.public.fingerprint) continue;
|
|
555
|
+
for (const peer of new Set([...member.exposedPeers, USER])) {
|
|
556
|
+
const id = `${peer}@${member.label}`;
|
|
557
|
+
for (const row of this.options.bus.queueList(id, true)) {
|
|
558
|
+
if (row.state === "needs_review" && row.originals.some(isInvocationKind) && !this.options.bus.remoteDisposition(row.id)) held.add(row.id);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
} catch { /* Unknown Bus state never grants a proof resume by itself. */ }
|
|
563
|
+
this.personHeldInvocations.set(binding.name, held);
|
|
564
|
+
}
|
|
366
565
|
queueChanged(): void {
|
|
367
566
|
if (this.timer) clearTimeout(this.timer);
|
|
368
567
|
this.timer = undefined; if (this.stopped || !this.bindings.size) return;
|
|
369
568
|
let next = this.options.bus.nextRemoteExpiry();
|
|
370
|
-
for (const { row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
|
|
371
|
-
const due = Math.min(row.expiresAt, row.ackDeadline ?? Infinity); next = next === undefined ? due : Math.min(next, due);
|
|
569
|
+
for (const { binding, row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
|
|
570
|
+
const due = Math.min(row.expiresAt, binding.online ? row.ackDeadline ?? Infinity : Infinity); next = next === undefined ? due : Math.min(next, due);
|
|
372
571
|
}
|
|
373
572
|
if (next === undefined) return;
|
|
374
573
|
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
574
|
this.timer.unref?.();
|
|
575
|
+
this.resumeReadyIntents();
|
|
576
|
+
}
|
|
577
|
+
private resumeReadyIntents(): void {
|
|
578
|
+
if (this.stopped) return;
|
|
579
|
+
for (const binding of this.bindings.values()) {
|
|
580
|
+
if (!binding.online || !this.valid(binding)) continue;
|
|
581
|
+
try {
|
|
582
|
+
for (const row of binding.store.applicationOutbox(binding.name, true)) {
|
|
583
|
+
const remote = this.remotes.get(row.remotePeer);
|
|
584
|
+
if (remote?.readyToResume(row.deliveryId)) this.launchResume(binding, row);
|
|
585
|
+
}
|
|
586
|
+
} catch { /* An actual future queue, receipt or online event retries the scan. */ }
|
|
587
|
+
}
|
|
376
588
|
}
|
|
377
589
|
tick(): void {
|
|
378
590
|
if (this.stopped) return;
|
|
@@ -380,12 +592,104 @@ export class RoomChat {
|
|
|
380
592
|
for (const binding of this.bindings.values()) if (this.valid(binding)) {
|
|
381
593
|
try {
|
|
382
594
|
for (const row of binding.store.expireApplications(binding.name)) this.outcome(binding, row);
|
|
595
|
+
if (binding.online) {
|
|
596
|
+
const due = binding.store.dueApplicationRetries(binding.name);
|
|
597
|
+
this.refreshOutgoing(binding);
|
|
598
|
+
for (const row of due) this.launchResume(binding, row);
|
|
599
|
+
}
|
|
383
600
|
this.reconcileDispositions(binding);
|
|
384
601
|
}
|
|
385
602
|
catch { binding.online = false; for (const [key, entry] of this.outgoing) if (entry.binding === binding) this.outgoing.delete(key); }
|
|
386
603
|
}
|
|
387
604
|
void this.retryPending(); this.queueChanged();
|
|
388
605
|
}
|
|
606
|
+
private async resumeActive(binding: Binding): Promise<void> {
|
|
607
|
+
if (!binding.online || !this.valid(binding)) return;
|
|
608
|
+
try {
|
|
609
|
+
const active = binding.store.applicationOutbox(binding.name, true).filter(row => (row.state === "dispatching" || row.state === "accepted")
|
|
610
|
+
&& !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId));
|
|
611
|
+
for (const row of active) this.remotes.get(row.remotePeer)?.reservePending(row.deliveryId);
|
|
612
|
+
for (const row of active) this.launchResume(binding, row);
|
|
613
|
+
} catch { /* The next authenticated online edge will retry durable rows. */ }
|
|
614
|
+
}
|
|
615
|
+
private launchResume(binding: Binding, row: RoomOutgoing): void {
|
|
616
|
+
const key = `${binding.name}:${row.deliveryId}`;
|
|
617
|
+
if (this.resuming.has(key)) return;
|
|
618
|
+
const remote = this.remotes.get(row.remotePeer);
|
|
619
|
+
if (!remote) return;
|
|
620
|
+
this.resuming.add(key);
|
|
621
|
+
void this.resumeApplication(binding, row, remote).catch(() => { remote.deferResume(row.deliveryId); })
|
|
622
|
+
.finally(() => { this.resuming.delete(key); try { this.queueChanged(); } catch { /* a later real event or ACK deadline retries */ } });
|
|
623
|
+
}
|
|
624
|
+
private storeProofCurrent(binding: Binding, row: RoomOutgoing, remote: RemotePeer, target: Target | undefined, digest: string | undefined): boolean {
|
|
625
|
+
try {
|
|
626
|
+
if (!target || !digest || !this.valid(binding) || binding.store.get(binding.name)?.current.room !== binding.room
|
|
627
|
+
|| binding.store.get(binding.name)?.current.digest !== digest || this.targets.get(row.remotePeer)?.binding !== binding
|
|
628
|
+
|| this.targets.get(row.remotePeer)?.member !== row.recipient || this.targets.get(row.remotePeer)?.peer !== row.peer
|
|
629
|
+
|| !remote.isPendingHead(row.deliveryId) || row.disposition || row.expiresAt <= this.now()) return false;
|
|
630
|
+
return binding.store.applicationOutbox(binding.name, true).some(saved => saved.deliveryId === row.deliveryId
|
|
631
|
+
&& saved.application === row.application && saved.remotePeer === row.remotePeer && saved.recipient === row.recipient
|
|
632
|
+
&& saved.peer === row.peer && (saved.state === "dispatching" || saved.state === "accepted") && !saved.disposition);
|
|
633
|
+
} catch { return false; }
|
|
634
|
+
}
|
|
635
|
+
private holdUnavailableProof(binding: Binding, row: RoomOutgoing, remote: RemotePeer, current: () => boolean): boolean {
|
|
636
|
+
const busRow = this.options.bus.queueShow(row.deliveryId);
|
|
637
|
+
if (busRow?.originals.some(isInvocationKind)) { remote.deferResume(row.deliveryId); return false; }
|
|
638
|
+
if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) { remote.deferResume(row.deliveryId); return false; }
|
|
639
|
+
try { binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current); }
|
|
640
|
+
catch { /* Bus hold is durable; startup reconciliation can finish the Store side. */ }
|
|
641
|
+
remote.settled(row.deliveryId);
|
|
642
|
+
this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
private async resumeApplication(binding: Binding, row: RoomOutgoing, remote: RemotePeer): Promise<void> {
|
|
646
|
+
if (this.personHeldInvocations.get(binding.name)?.has(row.deliveryId)) return;
|
|
647
|
+
if (!remote || !remote.isPendingHead(row.deliveryId)) return;
|
|
648
|
+
const heldRow = this.options.bus.queueShow(row.deliveryId);
|
|
649
|
+
const original = this.options.bus.remoteDeliveryEnvelope(remote, row.deliveryId);
|
|
650
|
+
const proofHoldReason = `${ROOM_PROOF_UNAVAILABLE_REASON} ${row.deliveryId}`;
|
|
651
|
+
if (heldRow?.state === "needs_review" && heldRow.reason === proofHoldReason && original) {
|
|
652
|
+
if (!row.disposition && (row.state === "dispatching" || row.state === "accepted")) {
|
|
653
|
+
const digest = binding.store.get(binding.name)?.current.digest;
|
|
654
|
+
const target = this.targets.get(row.remotePeer);
|
|
655
|
+
const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
|
|
656
|
+
const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", proofCurrent);
|
|
657
|
+
if (quarantined) { remote.settled(row.deliveryId); this.refreshOutgoing(binding); this.queueChanged(); }
|
|
658
|
+
else remote.deferResume(row.deliveryId);
|
|
659
|
+
}
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (!original || !binding.online || !this.valid(binding) || row.disposition || row.expiresAt <= this.now()) { remote.deferResume(row.deliveryId); return; }
|
|
663
|
+
const target = this.targets.get(row.remotePeer), state = binding.store.get(binding.name);
|
|
664
|
+
const digest = state?.current.digest;
|
|
665
|
+
const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
|
|
666
|
+
if (!target || !state || target.binding !== binding || !proofCurrent()) { remote.deferResume(row.deliveryId); return; }
|
|
667
|
+
const ready = () => binding.online && this.options.bus.remoteSendAllowed(remote);
|
|
668
|
+
if (!ready()) { remote.waitForReady(row.deliveryId); return; }
|
|
669
|
+
let readinessLost = false;
|
|
670
|
+
const current = () => {
|
|
671
|
+
const allowed = ready();
|
|
672
|
+
if (!allowed) readinessLost = true;
|
|
673
|
+
return allowed && proofCurrent();
|
|
674
|
+
};
|
|
675
|
+
let saved: RoomMessage | undefined;
|
|
676
|
+
try { saved = await binding.store.outgoingMessage(binding.name, row.application, current); }
|
|
677
|
+
catch (error) {
|
|
678
|
+
if (!proofCurrent()) { remote.deferResume(row.deliveryId); return; }
|
|
679
|
+
if (readinessLost || !ready()) { remote.waitForReady(row.deliveryId); return; }
|
|
680
|
+
if (permanentOutboundProofFailure(error)) { this.holdUnavailableProof(binding, row, remote, proofCurrent); return; }
|
|
681
|
+
remote.deferResume(row.deliveryId); return;
|
|
682
|
+
}
|
|
683
|
+
if (!proofCurrent()) { remote.deferResume(row.deliveryId); return; }
|
|
684
|
+
if (!ready()) { remote.waitForReady(row.deliveryId); return; }
|
|
685
|
+
if (!saved || saved.type !== "chat" || saved.envelope.id !== row.application || saved.envelope.to !== row.peer) {
|
|
686
|
+
this.holdUnavailableProof(binding, row, remote, proofCurrent); return;
|
|
687
|
+
}
|
|
688
|
+
const resumeState = row.state === "accepted" ? "accepted" : "dispatching";
|
|
689
|
+
if (!this.options.bus.resumeRemotePending(remote, row.deliveryId, resumeState, current)) return;
|
|
690
|
+
await remote.resume(original, row.deliveryId, row.expiresAt, saved.envelope);
|
|
691
|
+
this.refreshOutgoing(binding); this.queueChanged();
|
|
692
|
+
}
|
|
389
693
|
/** Global byte capacity can be freed by another room. This is event-coalesced, never polled. */
|
|
390
694
|
private capacityChanged(): void {
|
|
391
695
|
if (this.stopped || this.capacityRefreshPending) return;
|
|
@@ -407,19 +711,31 @@ export class RoomChat {
|
|
|
407
711
|
} catch { /* A completed person disposition must not fail because another room is unavailable. */ }
|
|
408
712
|
await Promise.allSettled(retries); this.capacityChanged();
|
|
409
713
|
}
|
|
410
|
-
private reconcileDispositions(binding: Binding):
|
|
411
|
-
|
|
714
|
+
private reconcileDispositions(binding: Binding): boolean {
|
|
715
|
+
let complete = true;
|
|
716
|
+
for (const row of binding.store.applicationOutbox(binding.name)) if (!row.disposition) {
|
|
412
717
|
const action = this.options.bus.remoteDisposition(row.deliveryId);
|
|
413
|
-
if (action)
|
|
718
|
+
if (action) try {
|
|
719
|
+
const disposed = binding.store.disposeApplication(binding.name, row.deliveryId, action, true, () => this.valid(binding)
|
|
720
|
+
&& binding.store.get(binding.name)?.current.digest === binding.digest && this.options.bus.remoteDisposition(row.deliveryId) === action);
|
|
721
|
+
if (disposed?.disposition === action) this.remotes.get(row.remotePeer)?.settled(row.deliveryId);
|
|
722
|
+
}
|
|
723
|
+
catch { complete = false; }
|
|
414
724
|
}
|
|
415
725
|
this.refreshOutgoing(binding);
|
|
726
|
+
return complete;
|
|
416
727
|
}
|
|
417
728
|
disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
|
|
418
729
|
if (this.options.bus.remoteDisposition(deliveryId) !== action) return;
|
|
419
730
|
for (const binding of this.bindings.values()) {
|
|
420
|
-
const row = binding.store.applicationOutbox(binding.name
|
|
731
|
+
const row = binding.store.applicationOutbox(binding.name).find(row => row.deliveryId === deliveryId && row.remotePeer === peer);
|
|
421
732
|
if (!row) continue;
|
|
422
|
-
binding.
|
|
733
|
+
const room = binding.room, digest = binding.digest;
|
|
734
|
+
const disposed = binding.store.disposeApplication(binding.name, deliveryId, action, true, () => this.valid(binding)
|
|
735
|
+
&& binding.store.get(binding.name)?.current.room === room && binding.store.get(binding.name)?.current.digest === digest
|
|
736
|
+
&& this.options.bus.remoteDisposition(deliveryId) === action);
|
|
737
|
+
if (disposed?.disposition === action) this.remotes.get(peer)?.settled(deliveryId);
|
|
738
|
+
this.personHeldInvocations.get(binding.name)?.delete(deliveryId);
|
|
423
739
|
this.refreshOutgoing(binding); break;
|
|
424
740
|
}
|
|
425
741
|
this.refreshTargets(); this.queueChanged(); void this.retryPending();
|