agent-comms 2.20.2 → 2.21.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/core/agent-registry.d.ts +38 -0
- package/dist/core/agent-registry.d.ts.map +1 -0
- package/dist/core/agent-registry.js +117 -0
- package/dist/core/agent-registry.js.map +1 -0
- package/dist/core/connection-approval.d.ts +48 -0
- package/dist/core/connection-approval.d.ts.map +1 -0
- package/dist/core/connection-approval.js +89 -0
- package/dist/core/connection-approval.js.map +1 -0
- package/dist/core/delivery-engine.d.ts +82 -0
- package/dist/core/delivery-engine.d.ts.map +1 -0
- package/dist/core/delivery-engine.js +464 -0
- package/dist/core/delivery-engine.js.map +1 -0
- package/dist/core/federation-bridge.d.ts +32 -0
- package/dist/core/federation-bridge.d.ts.map +1 -0
- package/dist/core/federation-bridge.js +113 -0
- package/dist/core/federation-bridge.js.map +1 -0
- package/dist/core/mesh-store-shared.d.ts +39 -0
- package/dist/core/mesh-store-shared.d.ts.map +1 -0
- package/dist/core/mesh-store-shared.js +32 -0
- package/dist/core/mesh-store-shared.js.map +1 -0
- package/dist/core/mesh-store.d.ts +65 -256
- package/dist/core/mesh-store.d.ts.map +1 -1
- package/dist/core/mesh-store.js +211 -1917
- package/dist/core/mesh-store.js.map +1 -1
- package/dist/core/peer-lifecycle.d.ts +36 -0
- package/dist/core/peer-lifecycle.d.ts.map +1 -0
- package/dist/core/peer-lifecycle.js +85 -0
- package/dist/core/peer-lifecycle.js.map +1 -0
- package/dist/core/room-lifecycle.d.ts +82 -0
- package/dist/core/room-lifecycle.d.ts.map +1 -0
- package/dist/core/room-lifecycle.js +459 -0
- package/dist/core/room-lifecycle.js.map +1 -0
- package/dist/core/room-messaging.d.ts +31 -0
- package/dist/core/room-messaging.d.ts.map +1 -0
- package/dist/core/room-messaging.js +120 -0
- package/dist/core/room-messaging.js.map +1 -0
- package/dist/core/room-protocol.d.ts +85 -0
- package/dist/core/room-protocol.d.ts.map +1 -0
- package/dist/core/room-protocol.js +479 -0
- package/dist/core/room-protocol.js.map +1 -0
- package/dist/core/room-wire-extensions.d.ts +36 -0
- package/dist/core/room-wire-extensions.d.ts.map +1 -0
- package/dist/core/room-wire-extensions.js +52 -0
- package/dist/core/room-wire-extensions.js.map +1 -0
- package/dist/core/stale-agent-checker.d.ts +28 -0
- package/dist/core/stale-agent-checker.d.ts.map +1 -0
- package/dist/core/stale-agent-checker.js +77 -0
- package/dist/core/stale-agent-checker.js.map +1 -0
- package/package.json +1 -1
package/dist/core/mesh-store.js
CHANGED
|
@@ -1,70 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MeshStore — transport-agnostic peer mesh for agent communication.
|
|
3
3
|
*
|
|
4
|
-
* Each bridge instance is a peer in the mesh. Peers discover each other
|
|
5
|
-
* via a coordinator (the first instance to bind the well-known port).
|
|
6
|
-
* All state is held in memory and synchronised between peers.
|
|
7
|
-
* Delivery events are pushed directly over the transport — no polling,
|
|
8
|
-
* no filesystem.
|
|
4
|
+
* Each bridge instance is a peer in the mesh. Peers discover each other via a coordinator (the first instance to bind the well-known port). All state is held in memory and synchronised between peers. Delivery events are pushed directly over the transport — no polling, no filesystem.
|
|
9
5
|
*
|
|
10
|
-
* Transport is set via setTransport() (e.g. WireMeshTransport for encrypted
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* from this store's own events getter, which needs the store to already
|
|
14
|
-
* exist.
|
|
6
|
+
* Transport is set via setTransport() (e.g. WireMeshTransport for encrypted connections) before init() or any other transport-using method is called -- there is no default, since every real bridge builds its own transport from this store's own events getter, which needs the store to already exist.
|
|
7
|
+
*
|
|
8
|
+
* MeshStore itself is an orchestrator: it owns the shared state (the core agents/rooms/messages/dms/deliveryQueues Maps and a handful of smaller fields) and constructs the collaborators that implement almost every behaviour against direct references into that state -- DeliveryEngine, FederationBridge, RoomProtocol, RoomMessaging, RoomLifecycle, AgentRegistry, ConnectionApproval, StaleAgentChecker, and PeerLifecycle. Every public method below that isn't inherently a MeshStore-level concern (transport/identity wiring, init/shutdown lifecycle, the events getter, mesh visibility, listener management, federation-adapter passthroughs) is a thin delegating wrapper to whichever collaborator now owns the real implementation, kept here only because CommsStore/MeshOnlyFeatures and a handful of concrete-only call sites (tests, bridge-mesh.ts, the web server, etc.) reach these names directly on a MeshStore-typed value.
|
|
15
9
|
*/
|
|
16
10
|
import * as os from "node:os";
|
|
17
11
|
import { nanoid } from "./nanoid.js";
|
|
18
12
|
import { CommsError } from "./store.js";
|
|
19
|
-
import { normaliseWireState } from "./wire-protocol.js";
|
|
20
|
-
import { dmRoomPath, ownerNamedRoomPath, parseRoomPath, slugRoomName, } from "./room-path.js";
|
|
21
13
|
import { DiscoveryManager } from "./discovery.js";
|
|
22
14
|
import { MdnsDiscoveryBackend } from "./discovery-mdns.js";
|
|
23
15
|
import { TailscaleDiscoveryBackend } from "./discovery-tailscale.js";
|
|
24
16
|
import { FederationManager } from "./federation.js";
|
|
25
17
|
import { getCertificateFingerprint } from "./identity.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
18
|
+
import { COORDINATOR_HOST } from "./mesh-store-shared.js";
|
|
19
|
+
import { DeliveryEngine } from "./delivery-engine.js";
|
|
20
|
+
import { FederationBridge } from "./federation-bridge.js";
|
|
21
|
+
import { RoomProtocol } from "./room-protocol.js";
|
|
22
|
+
import { RoomMessaging } from "./room-messaging.js";
|
|
23
|
+
import { RoomLifecycle } from "./room-lifecycle.js";
|
|
24
|
+
import { AgentRegistry } from "./agent-registry.js";
|
|
25
|
+
import { ConnectionApproval } from "./connection-approval.js";
|
|
26
|
+
import { StaleAgentChecker } from "./stale-agent-checker.js";
|
|
27
|
+
import { PeerLifecycle } from "./peer-lifecycle.js";
|
|
33
28
|
// ---------------------------------------------------------------------------
|
|
34
29
|
// Constants
|
|
35
30
|
// ---------------------------------------------------------------------------
|
|
36
31
|
const DEFAULT_COORDINATOR_PORT = 19876;
|
|
37
|
-
const COORDINATOR_HOST = "127.0.0.1";
|
|
38
|
-
/**
|
|
39
|
-
* Lifetime of a freshly minted room:member grant (owner root grant or member join/invite grant alike). Deliberately generous rather than the "short expires, periodic re-issue" pattern the design calls for to bound kick-convergence to gossip-independent expiry -- that re-issue mechanism is P3.7's own deliverable (riding room.members refreshes), and shipping a short expiry before it exists would let ordinary grants go stale with nothing to renew them. 30 days comfortably outlives any realistic room lifetime for now; P3.7 tightens this once re-issue-on-refresh lands.
|
|
40
|
-
*/
|
|
41
|
-
const ROOM_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|
42
|
-
/**
|
|
43
|
-
* Bound on pending delivery events held per target agent. Events beyond the
|
|
44
|
-
* bound drop oldest-first: a long-offline agent's queue cannot grow without
|
|
45
|
-
* limit in memory or in synced snapshots (#28).
|
|
46
|
-
*/
|
|
47
|
-
const MAX_QUEUED_DELIVERIES_PER_AGENT = 100;
|
|
48
|
-
/**
|
|
49
|
-
* Merge an incoming append-only message history into the local one: add
|
|
50
|
-
* entries the local list does not have and union read receipts on the ones
|
|
51
|
-
* it does. Local ordering is preserved; unseen entries are appended.
|
|
52
|
-
*/
|
|
53
|
-
function mergeMessageHistories(local, incoming) {
|
|
54
|
-
const byId = new Map(local.map((m) => [m.id, m]));
|
|
55
|
-
for (const msg of incoming) {
|
|
56
|
-
const existing = byId.get(msg.id);
|
|
57
|
-
if (existing === undefined) {
|
|
58
|
-
local.push(msg);
|
|
59
|
-
byId.set(msg.id, msg);
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
for (const reader of msg.readBy) {
|
|
63
|
-
if (!existing.readBy.includes(reader))
|
|
64
|
-
existing.readBy.push(reader);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
32
|
// ---------------------------------------------------------------------------
|
|
69
33
|
// MeshStore
|
|
70
34
|
// ---------------------------------------------------------------------------
|
|
@@ -78,15 +42,26 @@ export class MeshStore {
|
|
|
78
42
|
dms = new Map();
|
|
79
43
|
deliveryQueues = new Map();
|
|
80
44
|
identityCache = new Map();
|
|
81
|
-
|
|
82
|
-
|
|
45
|
+
peerInfo = new Map();
|
|
46
|
+
localDeliveryKeys = new Set();
|
|
47
|
+
pendingMarkReadTimers = [];
|
|
48
|
+
/** DM paths this store has itself sent an outbound room.join for -- section 6's own basis for auto-approving the counterpart's reciprocal room.join without a second human decision. Shared between RoomProtocol (reads, in handleRoomJoin) and RoomLifecycle (writes, in requestDmAccess). Never cleared: a DM conversation, once opened, stays open, and holding a stale entry here costs nothing beyond a few bytes per DM this node has ever initiated. */
|
|
49
|
+
dmRequestsInitiatedByMe = new Set();
|
|
83
50
|
transport;
|
|
84
51
|
storeIdentity;
|
|
85
|
-
peerInfo = new Map();
|
|
86
|
-
staleCheckTimer;
|
|
87
52
|
isShutDown = false;
|
|
88
53
|
initialised = false;
|
|
89
|
-
|
|
54
|
+
discovery;
|
|
55
|
+
federation;
|
|
56
|
+
deliveryEngine;
|
|
57
|
+
federationBridge;
|
|
58
|
+
roomProtocol;
|
|
59
|
+
roomMessaging;
|
|
60
|
+
roomLifecycle;
|
|
61
|
+
agentRegistry;
|
|
62
|
+
connectionApproval;
|
|
63
|
+
staleAgentChecker;
|
|
64
|
+
peerLifecycle;
|
|
90
65
|
/** This store's own current AgentStatus, synchronously -- the value WireMeshTransport's presence re-advertisement timer reads on every tick. undefined before registerAgent has ever run (no self agent record exists yet), in which case there is nothing yet to advertise. */
|
|
91
66
|
get selfStatus() {
|
|
92
67
|
return this.agents.get(this.peerId)?.status;
|
|
@@ -96,17 +71,11 @@ export class MeshStore {
|
|
|
96
71
|
return (this.requireTransport().isCoordinator ||
|
|
97
72
|
this.requireTransport().hasCoordinatorConnection);
|
|
98
73
|
}
|
|
99
|
-
// -- Pending inbound connections awaiting approval --
|
|
100
|
-
pendingInboundConnections = new Map();
|
|
101
|
-
// -- Pending room.join requests awaiting owner approval, keyed by `${roomPath}::${requesterId}` -- the held-open manage-request's own resolve() function is stored here so acceptRoomJoin/rejectRoomJoin can settle it in place, mirroring pendingInboundConnections' own pattern one layer up (room admission, not mesh admission).
|
|
102
|
-
pendingRoomJoins = new Map();
|
|
103
|
-
// -- DM paths this store has itself sent an outbound room.join for -- section 6's own basis for auto-approving the counterpart's reciprocal room.join without a second human decision. Never cleared: a DM conversation, once opened, stays open, and holding a stale entry here costs nothing beyond a few bytes per DM this node has ever initiated.
|
|
104
|
-
dmRequestsInitiatedByMe = new Set();
|
|
105
74
|
onDelivery;
|
|
106
75
|
/** Fires for every state patch — both locally generated and remote. */
|
|
107
76
|
onPatch;
|
|
108
77
|
/**
|
|
109
|
-
* Fires for a transport-level error a caller may want to observe (a connection rejected for presenting a certificate that doesn't match its claimed peer ID, this store's own inability to join or create a mesh, etc).
|
|
78
|
+
* Fires for a transport-level error a caller may want to observe (a connection rejected for presenting a certificate that doesn't match its claimed peer ID, this store's own inability to join or create a mesh, etc). Left undefined by default (matching onDelivery/onPatch): a caller that wants visibility sets it, exactly like those two.
|
|
110
79
|
*/
|
|
111
80
|
onError;
|
|
112
81
|
/** Serialise the full mesh state for state_sync messages. */
|
|
@@ -119,10 +88,6 @@ export class MeshStore {
|
|
|
119
88
|
deliveryQueues: Object.fromEntries(this.deliveryQueues),
|
|
120
89
|
};
|
|
121
90
|
}
|
|
122
|
-
lastLocalDeliveryKey;
|
|
123
|
-
localDeliveryKeys = new Set();
|
|
124
|
-
discovery;
|
|
125
|
-
federation;
|
|
126
91
|
constructor(coordinatorPort = DEFAULT_COORDINATOR_PORT) {
|
|
127
92
|
this.peerId = nanoid(8);
|
|
128
93
|
this.startedAt = new Date().toISOString();
|
|
@@ -131,16 +96,102 @@ export class MeshStore {
|
|
|
131
96
|
this.discovery = new DiscoveryManager();
|
|
132
97
|
this.discovery.registerBackend(new MdnsDiscoveryBackend());
|
|
133
98
|
this.discovery.registerBackend(new TailscaleDiscoveryBackend());
|
|
99
|
+
this.deliveryEngine = new DeliveryEngine({
|
|
100
|
+
agents: this.agents,
|
|
101
|
+
rooms: this.rooms,
|
|
102
|
+
messages: this.messages,
|
|
103
|
+
dms: this.dms,
|
|
104
|
+
deliveryQueues: this.deliveryQueues,
|
|
105
|
+
localDeliveryKeys: this.localDeliveryKeys,
|
|
106
|
+
pendingMarkReadTimers: this.pendingMarkReadTimers,
|
|
107
|
+
getPeerId: () => this.peerId,
|
|
108
|
+
requireIdentity: () => this.requireIdentity(),
|
|
109
|
+
requireTransport: () => this.requireTransport(),
|
|
110
|
+
getOnDelivery: () => this.onDelivery,
|
|
111
|
+
getOnPatch: () => this.onPatch,
|
|
112
|
+
isShutDown: () => this.isShutDown,
|
|
113
|
+
// RoomProtocol doesn't exist yet at this point in the constructor -- this closure resolves `this.roomProtocol` lazily, only once markRead actually calls it at runtime, well after the constructor has finished. Mirrors the lazy-`this`-capture pattern FederationManager's own callbacks use below.
|
|
114
|
+
sendRoomRequestToMember: (memberId, roomPath, token, params) => this.roomProtocol.sendRoomRequestToMember(memberId, roomPath, token, params),
|
|
115
|
+
});
|
|
116
|
+
this.federationBridge = new FederationBridge({
|
|
117
|
+
agents: this.agents,
|
|
118
|
+
rooms: this.rooms,
|
|
119
|
+
messages: this.messages,
|
|
120
|
+
deliveryEngine: this.deliveryEngine,
|
|
121
|
+
});
|
|
134
122
|
// Federation manager — coordinator-to-coordinator links
|
|
135
123
|
this.federation = new FederationManager(this.peerId, // mesh ID is the coordinator's peer ID
|
|
136
|
-
`mesh-${this.peerId}`,
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
124
|
+
`mesh-${this.peerId}`, this.federationBridge);
|
|
125
|
+
this.roomProtocol = new RoomProtocol({
|
|
126
|
+
rooms: this.rooms,
|
|
127
|
+
messages: this.messages,
|
|
128
|
+
dms: this.dms,
|
|
129
|
+
agents: this.agents,
|
|
130
|
+
dmRequestsInitiatedByMe: this.dmRequestsInitiatedByMe,
|
|
131
|
+
getPeerId: () => this.peerId,
|
|
132
|
+
requireIdentity: () => this.requireIdentity(),
|
|
133
|
+
requireTransport: () => this.requireTransport(),
|
|
134
|
+
deliveryEngine: this.deliveryEngine,
|
|
135
|
+
// RoomLifecycle doesn't exist yet at this point -- deferred the same way DeliveryEngine's sendRoomRequestToMember closure above is.
|
|
136
|
+
revokeMemberGrant: (roomId, memberId) => this.roomLifecycle.revokeMemberGrant(roomId, memberId),
|
|
137
|
+
});
|
|
138
|
+
this.roomMessaging = new RoomMessaging({
|
|
139
|
+
rooms: this.rooms,
|
|
140
|
+
messages: this.messages,
|
|
141
|
+
dms: this.dms,
|
|
142
|
+
agents: this.agents,
|
|
143
|
+
requireIdentity: () => this.requireIdentity(),
|
|
144
|
+
roomProtocol: this.roomProtocol,
|
|
145
|
+
federation: this.federation,
|
|
146
|
+
});
|
|
147
|
+
this.roomLifecycle = new RoomLifecycle({
|
|
148
|
+
rooms: this.rooms,
|
|
149
|
+
messages: this.messages,
|
|
150
|
+
agents: this.agents,
|
|
151
|
+
dmRequestsInitiatedByMe: this.dmRequestsInitiatedByMe,
|
|
152
|
+
getPeerId: () => this.peerId,
|
|
153
|
+
requireIdentity: () => this.requireIdentity(),
|
|
154
|
+
requireTransport: () => this.requireTransport(),
|
|
155
|
+
deliveryEngine: this.deliveryEngine,
|
|
156
|
+
federation: this.federation,
|
|
157
|
+
});
|
|
158
|
+
this.agentRegistry = new AgentRegistry({
|
|
159
|
+
agents: this.agents,
|
|
160
|
+
identityCache: this.identityCache,
|
|
161
|
+
startedAt: this.startedAt,
|
|
162
|
+
getPeerId: () => this.peerId,
|
|
163
|
+
deliveryEngine: this.deliveryEngine,
|
|
164
|
+
federation: this.federation,
|
|
165
|
+
});
|
|
166
|
+
this.connectionApproval = new ConnectionApproval({
|
|
167
|
+
agents: this.agents,
|
|
168
|
+
peerInfo: this.peerInfo,
|
|
169
|
+
startedAt: this.startedAt,
|
|
170
|
+
getPeerId: () => this.peerId,
|
|
171
|
+
requireTransport: () => this.requireTransport(),
|
|
172
|
+
getOnDelivery: () => this.onDelivery,
|
|
173
|
+
queueDelivery: (agentId, event) => {
|
|
174
|
+
this.deliveryEngine.queueDelivery(agentId, event);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
this.staleAgentChecker = new StaleAgentChecker({
|
|
178
|
+
agents: this.agents,
|
|
179
|
+
deliveryQueues: this.deliveryQueues,
|
|
180
|
+
identityCache: this.identityCache,
|
|
181
|
+
peerInfo: this.peerInfo,
|
|
182
|
+
notifyRoomsOfStatus: (agentId, status) => this.deliveryEngine.notifyRoomsOfStatus(agentId, status),
|
|
183
|
+
broadcastPatch: (patch) => this.deliveryEngine.broadcastPatch(patch),
|
|
184
|
+
});
|
|
185
|
+
this.peerLifecycle = new PeerLifecycle({
|
|
186
|
+
peerInfo: this.peerInfo,
|
|
187
|
+
agents: this.agents,
|
|
188
|
+
coordinatorPort: this.coordinatorPort,
|
|
189
|
+
getPeerId: () => this.peerId,
|
|
190
|
+
requireTransport: () => this.requireTransport(),
|
|
191
|
+
serialise: () => this.serialise(),
|
|
192
|
+
roomProtocol: this.roomProtocol,
|
|
193
|
+
deliveryEngine: this.deliveryEngine,
|
|
194
|
+
staleAgentChecker: this.staleAgentChecker,
|
|
144
195
|
});
|
|
145
196
|
}
|
|
146
197
|
/** Sets the transport (e.g. WireMeshTransport for encrypted connections). Must be called before init() or any other transport-using method. */
|
|
@@ -181,12 +232,7 @@ export class MeshStore {
|
|
|
181
232
|
});
|
|
182
233
|
// Try joining an existing mesh; fall back to becoming coordinator.
|
|
183
234
|
//
|
|
184
|
-
// Single attempt: connect to an existing coordinator, or become one.
|
|
185
|
-
// If the coordinator port is occupied but unresponsive (e.g. an orphan
|
|
186
|
-
// process from a previous session), degrade gracefully instead of
|
|
187
|
-
// retrying. Retrying tls.connect after a failed handshake to a
|
|
188
|
-
// non-TLS endpoint can freeze the event loop (Node.js TLS session
|
|
189
|
-
// cache bug), so we only try once.
|
|
235
|
+
// Single attempt: connect to an existing coordinator, or become one. If the coordinator port is occupied but unresponsive (e.g. an orphan process from a previous session), degrade gracefully instead of retrying. Retrying tls.connect after a failed handshake to a non-TLS endpoint can freeze the event loop (Node.js TLS session cache bug), so we only try once.
|
|
190
236
|
let connected = false;
|
|
191
237
|
try {
|
|
192
238
|
await this.requireTransport().connectToCoordinator(COORDINATOR_HOST, this.coordinatorPort, this.peerId, this.requireTransport().dataPort);
|
|
@@ -195,7 +241,7 @@ export class MeshStore {
|
|
|
195
241
|
catch {
|
|
196
242
|
try {
|
|
197
243
|
await this.requireTransport().becomeCoordinator(COORDINATOR_HOST, this.coordinatorPort);
|
|
198
|
-
this.
|
|
244
|
+
this.staleAgentChecker.start();
|
|
199
245
|
connected = true;
|
|
200
246
|
}
|
|
201
247
|
catch (coordErr) {
|
|
@@ -214,1839 +260,189 @@ export class MeshStore {
|
|
|
214
260
|
this.requireTransport().unref();
|
|
215
261
|
}
|
|
216
262
|
// -----------------------------------------------------------------------
|
|
217
|
-
// TransportEvents — callbacks from the transport layer
|
|
218
|
-
// -----------------------------------------------------------------------
|
|
219
|
-
handlePeerList(peers) {
|
|
220
|
-
for (const peer of peers) {
|
|
221
|
-
this.peerInfo.set(peer.id, peer);
|
|
222
|
-
// The list always includes this store's own entry — dialling yourself is a wasted connection attempt (and, on some platforms, an immediate self-inflicted ECONNRESET) that never needs to happen.
|
|
223
|
-
if (peer.id === this.peerId)
|
|
224
|
-
continue;
|
|
225
|
-
void this.requireTransport().connectToPeer(peer, this.peerId);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
handlePeerJoined(peer) {
|
|
229
|
-
this.peerInfo.set(peer.id, peer);
|
|
230
|
-
if (peer.id === this.peerId)
|
|
231
|
-
return;
|
|
232
|
-
void this.requireTransport().connectToPeer(peer, this.peerId);
|
|
233
|
-
}
|
|
234
|
-
async handleIntroduction(handle, msg) {
|
|
235
|
-
const newPeer = {
|
|
236
|
-
id: msg.peerId,
|
|
237
|
-
port: msg.dataPort,
|
|
238
|
-
startedAt: new Date().toISOString(),
|
|
239
|
-
};
|
|
240
|
-
this.peerInfo.set(msg.peerId, newPeer);
|
|
241
|
-
// Send full peer list to the new peer
|
|
242
|
-
const peerList = {
|
|
243
|
-
method: "peer_list",
|
|
244
|
-
peers: [...this.peerInfo.values()],
|
|
245
|
-
};
|
|
246
|
-
await this.requireTransport().send(handle, peerList);
|
|
247
|
-
// Broadcast arrival to all existing peers
|
|
248
|
-
const joined = { method: "peer_joined", peer: newPeer };
|
|
249
|
-
await this.requireTransport().broadcast(joined);
|
|
250
|
-
// Connect to the new peer's data server
|
|
251
|
-
void this.requireTransport().connectToPeer(newPeer, this.peerId);
|
|
252
|
-
}
|
|
253
|
-
async handlePeerConnected(handle, _info) {
|
|
254
|
-
// If we have state and the peer doesn't, send state sync
|
|
255
|
-
if (this.agents.size > 0) {
|
|
256
|
-
const state = this.serialise();
|
|
257
|
-
await this.requireTransport().send(handle, {
|
|
258
|
-
method: "state_sync",
|
|
259
|
-
state,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
await this.flushPendingRoomRequests(handle.id);
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* Merge a peer's state snapshot into the local state. Agents and rooms
|
|
266
|
-
* accept the incoming copy when it carries a revision at least as high as
|
|
267
|
-
* the local one, so a peer holding stale entities converges when it
|
|
268
|
-
* receives a fresher snapshot, while its own stale copies are rejected by
|
|
269
|
-
* peers that stayed current (#27). Message and DM histories are
|
|
270
|
-
* append-only: add unseen entries and union read receipts.
|
|
271
|
-
*/
|
|
272
|
-
applyStateSync(state) {
|
|
273
|
-
const incoming = {
|
|
274
|
-
agents: new Map(Object.entries(state.agents)),
|
|
275
|
-
rooms: new Map(Object.entries(state.rooms)),
|
|
276
|
-
messages: new Map(Object.entries(state.messages)),
|
|
277
|
-
dms: new Map(Object.entries(state.dms)),
|
|
278
|
-
};
|
|
279
|
-
{
|
|
280
|
-
for (const [id, agent] of incoming.agents) {
|
|
281
|
-
const existingVersion = this.agents.get(id)?.version;
|
|
282
|
-
if (existingVersion !== undefined && agent.version < existingVersion) {
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
if (existingVersion === agent.version) {
|
|
286
|
-
const existing = this.agents.get(id);
|
|
287
|
-
if (existing) {
|
|
288
|
-
for (const r of existing.subscribedRooms) {
|
|
289
|
-
if (!agent.subscribedRooms.includes(r))
|
|
290
|
-
agent.subscribedRooms.push(r);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
this.agents.set(id, agent);
|
|
295
|
-
}
|
|
296
|
-
for (const [id, room] of incoming.rooms) {
|
|
297
|
-
const existingVersion = this.rooms.get(id)?.version;
|
|
298
|
-
if (existingVersion !== undefined && room.version < existingVersion) {
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
this.mergeRoom(room);
|
|
302
|
-
}
|
|
303
|
-
for (const [id, msgs] of incoming.messages) {
|
|
304
|
-
const existing = this.messages.get(id);
|
|
305
|
-
if (existing === undefined) {
|
|
306
|
-
this.messages.set(id, msgs);
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
mergeMessageHistories(existing, msgs);
|
|
310
|
-
}
|
|
311
|
-
for (const [id, dmMsgs] of incoming.dms) {
|
|
312
|
-
const existing = this.dms.get(id);
|
|
313
|
-
if (existing === undefined) {
|
|
314
|
-
this.dms.set(id, dmMsgs);
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
mergeMessageHistories(existing, dmMsgs);
|
|
318
|
-
}
|
|
319
|
-
for (const [agentId, events] of Object.entries(state.deliveryQueues)) {
|
|
320
|
-
const seen = new Set((this.deliveryQueues.get(agentId) ?? []).map((e) => JSON.stringify(e)));
|
|
321
|
-
for (const event of events) {
|
|
322
|
-
if (seen.has(JSON.stringify(event)))
|
|
323
|
-
continue;
|
|
324
|
-
this.queueDelivery(agentId, event);
|
|
325
|
-
// Replay fires only for events with consumption evidence (#28):
|
|
326
|
-
// messages are consumed by reading (readBy), invites by acceptance
|
|
327
|
-
// or decline (no longer in the invited list). Transient
|
|
328
|
-
// notifications (member_joined, room_members, connection_request,
|
|
329
|
-
// delivery status) carry no consumption evidence, so replaying
|
|
330
|
-
// them could only ever duplicate-notify; the state they describe
|
|
331
|
-
// arrives via the synced room and agent records instead. They
|
|
332
|
-
// still merge into the queue, so drain bridges see them.
|
|
333
|
-
if (event.type === "room_message" || event.type === "dm") {
|
|
334
|
-
this.fireLocalDelivery(agentId, event);
|
|
335
|
-
}
|
|
336
|
-
else if (event.type === "room_invite") {
|
|
337
|
-
const stillInvited = this.rooms
|
|
338
|
-
.get(event.room)
|
|
339
|
-
?.invited.includes(agentId);
|
|
340
|
-
if (stillInvited === true) {
|
|
341
|
-
this.fireLocalDelivery(agentId, event);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
async handleDataMessage(handle, msg) {
|
|
349
|
-
if (msg.method === "state_sync") {
|
|
350
|
-
this.applyStateSync(normaliseWireState(msg.state));
|
|
351
|
-
}
|
|
352
|
-
else if (msg.method === "state_update") {
|
|
353
|
-
await this.applyPatch(msg.patch);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
async handleBecomeCoordinator(peerList) {
|
|
357
|
-
// Take over as coordinator using the data server we already have
|
|
358
|
-
await this.requireTransport().becomeCoordinator(COORDINATOR_HOST, this.coordinatorPort);
|
|
359
|
-
this.peerInfo.clear();
|
|
360
|
-
for (const peer of peerList) {
|
|
361
|
-
this.peerInfo.set(peer.id, peer);
|
|
362
|
-
void this.requireTransport().connectToPeer(peer, this.peerId);
|
|
363
|
-
}
|
|
364
|
-
this.startStaleCheck();
|
|
365
|
-
}
|
|
366
|
-
handlePeerDisconnected(handle) {
|
|
367
|
-
this.peerInfo.delete(handle.id);
|
|
368
|
-
}
|
|
369
|
-
// -----------------------------------------------------------------------
|
|
370
|
-
// Connection approval
|
|
371
|
-
// -----------------------------------------------------------------------
|
|
372
|
-
handleConnectionRequest(handle, request) {
|
|
373
|
-
this.pendingInboundConnections.set(handle.id, {
|
|
374
|
-
peerId: request.peerId,
|
|
375
|
-
dataPort: request.dataPort,
|
|
376
|
-
name: request.name,
|
|
377
|
-
fingerprint: request.fingerprint,
|
|
378
|
-
});
|
|
379
|
-
// Deliver connection_request event to the owning agent
|
|
380
|
-
const connectionId = handle.id;
|
|
381
|
-
const event = {
|
|
382
|
-
type: "connection_request",
|
|
383
|
-
connectionId,
|
|
384
|
-
peerId: request.peerId,
|
|
385
|
-
dataPort: request.dataPort,
|
|
386
|
-
name: request.name,
|
|
387
|
-
fingerprint: request.fingerprint,
|
|
388
|
-
};
|
|
389
|
-
this.queueDelivery(this.peerId, event);
|
|
390
|
-
if (this.onDelivery) {
|
|
391
|
-
void this.onDelivery(this.peerId, event);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
/** Accept a pending inbound connection. */
|
|
395
|
-
async acceptConnection(connectionId) {
|
|
396
|
-
const pending = this.pendingInboundConnections.get(connectionId);
|
|
397
|
-
if (!pending) {
|
|
398
|
-
throw new Error(`No pending connection ${connectionId}`);
|
|
399
|
-
}
|
|
400
|
-
this.pendingInboundConnections.delete(connectionId);
|
|
401
|
-
const handle = { id: connectionId };
|
|
402
|
-
await this.requireTransport().acceptConnection(handle);
|
|
403
|
-
}
|
|
404
|
-
/** Reject a pending inbound connection. */
|
|
405
|
-
async rejectConnection(connectionId, reason) {
|
|
406
|
-
const pending = this.pendingInboundConnections.get(connectionId);
|
|
407
|
-
if (!pending) {
|
|
408
|
-
throw new Error(`No pending connection ${connectionId}`);
|
|
409
|
-
}
|
|
410
|
-
this.pendingInboundConnections.delete(connectionId);
|
|
411
|
-
const handle = { id: connectionId };
|
|
412
|
-
await this.requireTransport().rejectConnection(handle, reason);
|
|
413
|
-
}
|
|
414
|
-
/** List all pending inbound connections awaiting approval. */
|
|
415
|
-
listPendingConnections() {
|
|
416
|
-
return [...this.pendingInboundConnections.entries()].map(([id, info]) => ({
|
|
417
|
-
connectionId: id,
|
|
418
|
-
...info,
|
|
419
|
-
}));
|
|
420
|
-
}
|
|
421
|
-
/** Initiate an outbound connection to a remote coordinator requiring approval.
|
|
422
|
-
* Fires the connect_request and returns immediately. The connection
|
|
423
|
-
* completes asynchronously when the coordinator accepts or rejects. */
|
|
424
|
-
connectToRemote(host, port) {
|
|
425
|
-
const agent = this.agents.get(this.peerId);
|
|
426
|
-
// Fire-and-forget: don't await the full approval handshake.
|
|
427
|
-
// The coordinator will either accept (triggering normal introduction flow)
|
|
428
|
-
// or reject (closing the socket). Handle rejection to avoid unhandled rejection.
|
|
429
|
-
this.requireTransport()
|
|
430
|
-
.connectToRemote(host, port, this.peerId, this.requireTransport().dataPort, agent?.name ?? "", "")
|
|
431
|
-
.catch(() => {
|
|
432
|
-
// Rejection is expected when the coordinator denies the connection.
|
|
433
|
-
// Log silently — the calling tool already returned success.
|
|
434
|
-
});
|
|
435
|
-
return Promise.resolve();
|
|
436
|
-
}
|
|
437
|
-
/** Start only the data server without connecting to a coordinator.
|
|
438
|
-
* Used for testing scenarios where the peer connects via connectToRemote. */
|
|
439
|
-
async startDataServerOnly() {
|
|
440
|
-
await this.requireTransport().startDataServer();
|
|
441
|
-
this.peerInfo.set(this.peerId, {
|
|
442
|
-
id: this.peerId,
|
|
443
|
-
port: this.requireTransport().dataPort,
|
|
444
|
-
startedAt: this.startedAt,
|
|
445
|
-
});
|
|
446
|
-
this.requireTransport().unref();
|
|
447
|
-
}
|
|
448
|
-
// -----------------------------------------------------------------------
|
|
449
263
|
// Transport events accessor (for bridges to wire up)
|
|
450
264
|
// -----------------------------------------------------------------------
|
|
451
265
|
/** Returns the TransportEvents object that bridges should pass to the transport constructor. */
|
|
452
266
|
get events() {
|
|
453
267
|
return {
|
|
454
268
|
onMessage: (handle, msg) => {
|
|
455
|
-
void this.handleDataMessage(handle, msg);
|
|
269
|
+
void this.peerLifecycle.handleDataMessage(handle, msg);
|
|
456
270
|
},
|
|
457
271
|
onPeerConnected: (handle, info) => {
|
|
458
|
-
void this.handlePeerConnected(handle, info);
|
|
272
|
+
void this.peerLifecycle.handlePeerConnected(handle, info);
|
|
459
273
|
},
|
|
460
274
|
onPeerDisconnected: (handle) => {
|
|
461
|
-
this.handlePeerDisconnected(handle);
|
|
275
|
+
this.peerLifecycle.handlePeerDisconnected(handle);
|
|
462
276
|
},
|
|
463
277
|
onIntroduction: (handle, msg) => {
|
|
464
|
-
void this.handleIntroduction(handle, msg);
|
|
278
|
+
void this.peerLifecycle.handleIntroduction(handle, msg);
|
|
465
279
|
},
|
|
466
280
|
onPeerList: (peers) => {
|
|
467
|
-
this.handlePeerList(peers);
|
|
281
|
+
this.peerLifecycle.handlePeerList(peers);
|
|
468
282
|
},
|
|
469
283
|
onPeerJoined: (peer) => {
|
|
470
|
-
this.handlePeerJoined(peer);
|
|
284
|
+
this.peerLifecycle.handlePeerJoined(peer);
|
|
471
285
|
},
|
|
472
286
|
onBecomeCoordinator: (peerList) => {
|
|
473
|
-
void this.handleBecomeCoordinator(peerList);
|
|
287
|
+
void this.peerLifecycle.handleBecomeCoordinator(peerList);
|
|
474
288
|
},
|
|
475
289
|
onConnectionRequest: (handle, request) => {
|
|
476
|
-
this.handleConnectionRequest(handle, request);
|
|
290
|
+
this.connectionApproval.handleConnectionRequest(handle, request);
|
|
477
291
|
},
|
|
478
292
|
onError: (error) => {
|
|
479
293
|
this.onError?.(error);
|
|
480
294
|
},
|
|
481
295
|
onRevocationAnnounce: (entry) => {
|
|
482
|
-
void this.handleRevocationAnnounce(entry);
|
|
296
|
+
void this.deliveryEngine.handleRevocationAnnounce(entry);
|
|
483
297
|
},
|
|
484
298
|
onPresenceAdvert: (handle, status) => {
|
|
485
|
-
this.handlePresenceAdvert(handle.id, status);
|
|
299
|
+
this.deliveryEngine.handlePresenceAdvert(handle.id, status);
|
|
486
300
|
},
|
|
487
301
|
};
|
|
488
302
|
}
|
|
489
|
-
/** Applies a peer's gossiped presence status to this store's own local record, without broadcasting it back onto the mesh -- gossip itself is already how this fact propagates (each side's WireMeshTransport re-advertises its own status periodically), so re-broadcasting a received one would just bounce it around indefinitely. Silently ignored for a device-id this store doesn't yet have an agent record for: presence gossip is a transport-layer fact about an already-known peer's current status, not itself a source of truth for who that peer is -- that still comes only from agent_upsert/registerAgent, which carries the name/harness/cwd/tags a fabricated partial record here never could. */
|
|
490
|
-
handlePresenceAdvert(agentId, status) {
|
|
491
|
-
const agent = this.agents.get(agentId);
|
|
492
|
-
if (agent === undefined || agent.status === status)
|
|
493
|
-
return;
|
|
494
|
-
agent.status = status;
|
|
495
|
-
this.bump(agent);
|
|
496
|
-
void this.notifyRoomsOfStatus(agentId, status);
|
|
497
|
-
}
|
|
498
|
-
/** Verifies a gossiped revocation-entry and, if it verifies, records it in this store's own RevocationView -- future verifyRoomToken calls against this token's (token-id, issuer) pair fail with "revoked" from this point on. A failing entry is dropped silently: the same "hostile input produces a verdict, never a throw" contract verifyRevocationEntry itself already guarantees, so there is nothing further for a caller to react to. */
|
|
499
|
-
async handleRevocationAnnounce(entry) {
|
|
500
|
-
const { identity, revocation } = this.requireIdentity();
|
|
501
|
-
await revocation.record(entry, { identity });
|
|
502
|
-
}
|
|
503
|
-
/** Append to a target agent's delivery queue, bounded oldest-first (#28). */
|
|
504
|
-
queueDelivery(agentId, event) {
|
|
505
|
-
const arr = this.deliveryQueues.get(agentId) ?? [];
|
|
506
|
-
arr.push(event);
|
|
507
|
-
if (arr.length > MAX_QUEUED_DELIVERIES_PER_AGENT) {
|
|
508
|
-
arr.splice(0, arr.length - MAX_QUEUED_DELIVERIES_PER_AGENT);
|
|
509
|
-
}
|
|
510
|
-
this.deliveryQueues.set(agentId, arr);
|
|
511
|
-
}
|
|
512
303
|
/**
|
|
513
|
-
*
|
|
514
|
-
* the version gate the caller already applied (an equal or higher version
|
|
515
|
-
* reaches here), while membership is always element-merged per agent by
|
|
516
|
-
* highest operation revision, so concurrent joins of different agents
|
|
517
|
-
* survive and a kick racing a join converges with the kick honoured
|
|
518
|
-
* (#27). The members and invited views are re-derived from the merged
|
|
519
|
-
* operations.
|
|
304
|
+
* Room verb handlers this store registers with its own WireMeshTransport, keyed by params.verb per room-router.ts's own dispatch discipline.
|
|
520
305
|
*/
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if (existing === undefined) {
|
|
524
|
-
this.refreshMembership(incoming);
|
|
525
|
-
this.rooms.set(incoming.id, incoming);
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
existing.version = Math.max(existing.version, incoming.version);
|
|
529
|
-
existing.name = incoming.name;
|
|
530
|
-
existing.type = incoming.type;
|
|
531
|
-
existing.owner = incoming.owner;
|
|
532
|
-
existing.createdAt = incoming.createdAt;
|
|
533
|
-
existing.description = incoming.description;
|
|
534
|
-
if (incoming.federated !== undefined)
|
|
535
|
-
existing.federated = incoming.federated;
|
|
536
|
-
existing.memberJoins = MeshStore.mergeMemberOps(existing.memberJoins, incoming.memberJoins);
|
|
537
|
-
existing.memberLeaves = MeshStore.mergeMemberOps(existing.memberLeaves, incoming.memberLeaves);
|
|
538
|
-
existing.invitedJoins = MeshStore.mergeMemberOps(existing.invitedJoins, incoming.invitedJoins);
|
|
539
|
-
existing.invitedLeaves = MeshStore.mergeMemberOps(existing.invitedLeaves, incoming.invitedLeaves);
|
|
540
|
-
this.refreshMembership(existing);
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Derive the members and invited views from the per-agent operation maps.
|
|
544
|
-
* An agent is in the list when their latest join strictly outranks their
|
|
545
|
-
* latest leave; equal revisions mean the leave wins, so a kick racing a
|
|
546
|
-
* concurrent join converges with the kick honoured (#27).
|
|
547
|
-
*/
|
|
548
|
-
refreshMembership(room) {
|
|
549
|
-
room.members = Object.keys(room.memberJoins).filter((id) => (room.memberJoins[id] ?? 0) > (room.memberLeaves[id] ?? 0));
|
|
550
|
-
room.invited = Object.keys(room.invitedJoins).filter((id) => (room.invitedJoins[id] ?? 0) > (room.invitedLeaves[id] ?? 0));
|
|
551
|
-
}
|
|
552
|
-
/** Record a membership operation at the room's current revision. */
|
|
553
|
-
recordMemberOp(room, list, op, agentId) {
|
|
554
|
-
const joins = list === "member" ? room.memberJoins : room.invitedJoins;
|
|
555
|
-
const leaves = list === "member" ? room.memberLeaves : room.invitedLeaves;
|
|
556
|
-
const stamp = room.version;
|
|
557
|
-
if (op === "join")
|
|
558
|
-
joins[agentId] = stamp;
|
|
559
|
-
else
|
|
560
|
-
leaves[agentId] = stamp;
|
|
561
|
-
}
|
|
562
|
-
/** Merge per-agent operation maps by highest revision per agent. */
|
|
563
|
-
static mergeMemberOps(local, incoming) {
|
|
564
|
-
const merged = { ...local };
|
|
565
|
-
for (const [id, stamp] of Object.entries(incoming)) {
|
|
566
|
-
if (stamp > (merged[id] ?? 0))
|
|
567
|
-
merged[id] = stamp;
|
|
568
|
-
}
|
|
569
|
-
return merged;
|
|
570
|
-
}
|
|
571
|
-
/** Bump an entity's sync revision; call before broadcasting a local mutation. */
|
|
572
|
-
bump(entity) {
|
|
573
|
-
entity.version += 1;
|
|
574
|
-
return entity;
|
|
575
|
-
}
|
|
576
|
-
// -----------------------------------------------------------------------
|
|
577
|
-
// State patch application
|
|
578
|
-
// -----------------------------------------------------------------------
|
|
579
|
-
async applyPatch(patch) {
|
|
580
|
-
switch (patch.type) {
|
|
581
|
-
case "agent_upsert": {
|
|
582
|
-
const existingAgent = this.agents.get(patch.agent.id);
|
|
583
|
-
if (existingAgent !== undefined &&
|
|
584
|
-
patch.agent.version < existingAgent.version) {
|
|
585
|
-
// Stale copy from a peer that missed updates (#27).
|
|
586
|
-
break;
|
|
587
|
-
}
|
|
588
|
-
const merged = patch.agent;
|
|
589
|
-
if (existingAgent?.version === patch.agent.version) {
|
|
590
|
-
// Concurrent mutations from the same base: keep subscriptions
|
|
591
|
-
// gained locally. A strictly higher version replaces the record.
|
|
592
|
-
for (const r of existingAgent.subscribedRooms) {
|
|
593
|
-
if (!merged.subscribedRooms.includes(r))
|
|
594
|
-
merged.subscribedRooms.push(r);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
this.agents.set(merged.id, merged);
|
|
598
|
-
break;
|
|
599
|
-
}
|
|
600
|
-
case "agent_offline": {
|
|
601
|
-
const agent = this.agents.get(patch.agentId);
|
|
602
|
-
if (agent) {
|
|
603
|
-
agent.status = "offline";
|
|
604
|
-
this.agents.set(patch.agentId, agent);
|
|
605
|
-
}
|
|
606
|
-
break;
|
|
607
|
-
}
|
|
608
|
-
case "room_upsert": {
|
|
609
|
-
const existing = this.rooms.get(patch.room.id);
|
|
610
|
-
if (existing !== undefined && patch.room.version < existing.version) {
|
|
611
|
-
// Stale copy from a peer that missed updates (#27).
|
|
612
|
-
break;
|
|
613
|
-
}
|
|
614
|
-
this.mergeRoom(patch.room);
|
|
615
|
-
break;
|
|
616
|
-
}
|
|
617
|
-
case "room_delete":
|
|
618
|
-
this.rooms.delete(patch.roomId);
|
|
619
|
-
break;
|
|
620
|
-
case "message_add": {
|
|
621
|
-
const arr = this.messages.get(patch.roomId) ?? [];
|
|
622
|
-
arr.push(patch.message);
|
|
623
|
-
this.messages.set(patch.roomId, arr);
|
|
624
|
-
break;
|
|
625
|
-
}
|
|
626
|
-
case "dm_add": {
|
|
627
|
-
const arr = this.dms.get(patch.key) ?? [];
|
|
628
|
-
arr.push(patch.message);
|
|
629
|
-
this.dms.set(patch.key, arr);
|
|
630
|
-
break;
|
|
631
|
-
}
|
|
632
|
-
case "delivery": {
|
|
633
|
-
this.queueDelivery(patch.agentId, patch.event);
|
|
634
|
-
if (patch.agentId === this.peerId && this.onDelivery) {
|
|
635
|
-
// Deduplicate against local deliveries
|
|
636
|
-
const eventKey = JSON.stringify(patch.event);
|
|
637
|
-
if (this.localDeliveryKeys.has(eventKey))
|
|
638
|
-
break;
|
|
639
|
-
this.localDeliveryKeys.add(eventKey);
|
|
640
|
-
if (this.localDeliveryKeys.size > 50) {
|
|
641
|
-
const oldest = this.localDeliveryKeys.values().next().value;
|
|
642
|
-
if (oldest !== undefined)
|
|
643
|
-
this.localDeliveryKeys.delete(oldest);
|
|
644
|
-
}
|
|
645
|
-
void this.onDelivery(patch.agentId, patch.event);
|
|
646
|
-
// Auto-mark read — scheduled as a macrotask to yield to the event
|
|
647
|
-
// loop. Without this yield, the delivery → markRead → broadcast
|
|
648
|
-
// → peer receives → handleDataMessage chain monopolises the
|
|
649
|
-
// microtask queue and starves macrotasks (timers, new connections,
|
|
650
|
-
// sendRoomMessage return values).
|
|
651
|
-
const evt = patch.event;
|
|
652
|
-
const timer = setTimeout(() => {
|
|
653
|
-
if (this.isShutDown)
|
|
654
|
-
return;
|
|
655
|
-
if (evt.type === "room_message") {
|
|
656
|
-
void this.markRead(evt.message.id, this.peerId, evt.message.room);
|
|
657
|
-
}
|
|
658
|
-
else if (evt.type === "dm") {
|
|
659
|
-
void this.markRead(evt.message.id, this.peerId);
|
|
660
|
-
}
|
|
661
|
-
}, 0);
|
|
662
|
-
if (!this.isShutDown)
|
|
663
|
-
this.pendingMarkReadTimers.push(timer);
|
|
664
|
-
}
|
|
665
|
-
break;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
if (this.onPatch) {
|
|
669
|
-
await this.onPatch(patch);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
// -----------------------------------------------------------------------
|
|
673
|
-
// Broadcast
|
|
674
|
-
// -----------------------------------------------------------------------
|
|
675
|
-
async broadcastPatch(patch) {
|
|
676
|
-
await this.requireTransport().broadcast({ method: "state_update", patch });
|
|
677
|
-
if (this.onPatch) {
|
|
678
|
-
await this.onPatch(patch);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
async deliverLocallyAndBroadcast(agentId, event) {
|
|
682
|
-
// Local delivery
|
|
683
|
-
this.queueDelivery(agentId, event);
|
|
684
|
-
// Auto-emit delivered status for messages
|
|
685
|
-
if (event.type === "room_message") {
|
|
686
|
-
await this.emitDeliveryStatus(event.message.id, agentId, "delivered", event.message.room);
|
|
687
|
-
}
|
|
688
|
-
else if (event.type === "dm") {
|
|
689
|
-
await this.emitDeliveryStatus(event.message.id, agentId, "delivered");
|
|
690
|
-
}
|
|
691
|
-
this.fireLocalDelivery(agentId, event);
|
|
692
|
-
// Remote delivery
|
|
693
|
-
const patch = { type: "delivery", agentId, event };
|
|
694
|
-
await this.broadcastPatch(patch);
|
|
695
|
-
}
|
|
696
|
-
/**
|
|
697
|
-
* Fire onDelivery for an event targeting this peer's own agent, deduped
|
|
698
|
-
* against events already delivered in this process. Used both for live
|
|
699
|
-
* deliveries and for replays of events that accumulated while this
|
|
700
|
-
* process was down (#28): the dedup set is per-process, so a replayed
|
|
701
|
-
* event this process never saw fires, and one it already handled does
|
|
702
|
-
* not.
|
|
703
|
-
*/
|
|
704
|
-
fireLocalDelivery(agentId, event) {
|
|
705
|
-
if (agentId !== this.peerId || !this.onDelivery)
|
|
706
|
-
return;
|
|
707
|
-
// A room message or DM this agent has already read was already pushed
|
|
708
|
-
// and consumed: read receipts mutate the event between snapshots, so a
|
|
709
|
-
// plain structural key would miss and re-fire on the next sync (#28).
|
|
710
|
-
if ((event.type === "room_message" || event.type === "dm") &&
|
|
711
|
-
event.message.readBy.includes(agentId)) {
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
const eventKey = JSON.stringify(event);
|
|
715
|
-
if (this.localDeliveryKeys.has(eventKey))
|
|
716
|
-
return;
|
|
717
|
-
this.localDeliveryKeys.add(eventKey);
|
|
718
|
-
// Prevent unbounded growth — evict oldest when cap reached
|
|
719
|
-
if (this.localDeliveryKeys.size > 50) {
|
|
720
|
-
const oldest = this.localDeliveryKeys.values().next().value;
|
|
721
|
-
if (oldest !== undefined)
|
|
722
|
-
this.localDeliveryKeys.delete(oldest);
|
|
723
|
-
}
|
|
724
|
-
void this.onDelivery(agentId, event);
|
|
725
|
-
// Delivered to this process, so no longer pending for it. Peers that
|
|
726
|
-
// never fired the event keep their copies, which is what a restart
|
|
727
|
-
// replays from (#28). Key-based match: replayed events are JSON clones.
|
|
728
|
-
const queued = this.deliveryQueues.get(agentId);
|
|
729
|
-
if (queued !== undefined) {
|
|
730
|
-
const idx = queued.findIndex((e) => JSON.stringify(e) === eventKey);
|
|
731
|
-
if (idx !== -1)
|
|
732
|
-
queued.splice(idx, 1);
|
|
733
|
-
}
|
|
734
|
-
// Auto-mark read — scheduled as a macrotask to yield to the event loop.
|
|
735
|
-
const timer = setTimeout(() => {
|
|
736
|
-
if (this.isShutDown)
|
|
737
|
-
return;
|
|
738
|
-
if (event.type === "room_message") {
|
|
739
|
-
void this.markRead(event.message.id, agentId, event.message.room);
|
|
740
|
-
}
|
|
741
|
-
else if (event.type === "dm") {
|
|
742
|
-
void this.markRead(event.message.id, agentId);
|
|
743
|
-
}
|
|
744
|
-
}, 0);
|
|
745
|
-
if (!this.isShutDown)
|
|
746
|
-
this.pendingMarkReadTimers.push(timer);
|
|
747
|
-
}
|
|
748
|
-
async deliverToRoom(roomId, event, excludeAgent) {
|
|
749
|
-
const room = this.rooms.get(roomId);
|
|
750
|
-
if (!room)
|
|
751
|
-
return;
|
|
752
|
-
for (const memberId of room.members) {
|
|
753
|
-
if (memberId === excludeAgent)
|
|
754
|
-
continue;
|
|
755
|
-
await this.deliverLocallyAndBroadcast(memberId, event);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
async notifyRoomsOfStatus(agentId, status) {
|
|
759
|
-
const agent = this.agents.get(agentId);
|
|
760
|
-
if (!agent)
|
|
761
|
-
return;
|
|
762
|
-
for (const roomId of agent.subscribedRooms) {
|
|
763
|
-
await this.deliverToRoom(roomId, {
|
|
764
|
-
type: "member_status",
|
|
765
|
-
room: roomId,
|
|
766
|
-
agent: agentId,
|
|
767
|
-
status,
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
async notifyRoomsOfNameChange(agentId, oldName, newName) {
|
|
772
|
-
const agent = this.agents.get(agentId);
|
|
773
|
-
if (!agent)
|
|
774
|
-
return;
|
|
775
|
-
const event = {
|
|
776
|
-
type: "name_changed",
|
|
777
|
-
agent: agentId,
|
|
778
|
-
oldName,
|
|
779
|
-
newName,
|
|
780
|
-
};
|
|
781
|
-
for (const roomId of agent.subscribedRooms) {
|
|
782
|
-
await this.deliverToRoom(roomId, event, agentId);
|
|
783
|
-
}
|
|
784
|
-
// Also deliver to the agent itself so it sees confirmation
|
|
785
|
-
await this.deliverLocallyAndBroadcast(agentId, event);
|
|
786
|
-
}
|
|
787
|
-
async emitDeliveryStatus(messageId, agentId, status, room) {
|
|
788
|
-
// Find the sender for this message
|
|
789
|
-
const senderId = this.findMessageSender(messageId, room);
|
|
790
|
-
if (!senderId)
|
|
791
|
-
return;
|
|
792
|
-
await this.deliverLocallyAndBroadcast(senderId, {
|
|
793
|
-
type: "delivery_status",
|
|
794
|
-
messageId,
|
|
795
|
-
agent: agentId,
|
|
796
|
-
status,
|
|
797
|
-
room,
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
findMessageSender(messageId, room) {
|
|
801
|
-
if (room) {
|
|
802
|
-
const msgs = this.messages.get(room);
|
|
803
|
-
if (msgs) {
|
|
804
|
-
const msg = msgs.find((m) => m.id === messageId);
|
|
805
|
-
if (msg)
|
|
806
|
-
return msg.from;
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
else {
|
|
810
|
-
// DM — search all DM queues
|
|
811
|
-
for (const [, msgs] of this.dms) {
|
|
812
|
-
const msg = msgs.find((m) => m.id === messageId);
|
|
813
|
-
if (msg)
|
|
814
|
-
return msg.from;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
return undefined;
|
|
818
|
-
}
|
|
819
|
-
/** Like findMessageSender, but also returns the room-path a room.read needs to address: room itself for a room message, or the specific DM key (this.dms is keyed by dmRoomPath/"self:...", not the bare pair) the message was actually found under. */
|
|
820
|
-
findMessageLocation(messageId, room) {
|
|
821
|
-
if (room !== undefined) {
|
|
822
|
-
const msg = this.messages.get(room)?.find((m) => m.id === messageId);
|
|
823
|
-
return msg === undefined ? undefined : { roomPath: room, from: msg.from };
|
|
824
|
-
}
|
|
825
|
-
for (const [key, msgs] of this.dms) {
|
|
826
|
-
const msg = msgs.find((m) => m.id === messageId);
|
|
827
|
-
if (msg)
|
|
828
|
-
return { roomPath: key, from: msg.from };
|
|
829
|
-
}
|
|
830
|
-
return undefined;
|
|
831
|
-
}
|
|
832
|
-
/**
|
|
833
|
-
* Marks a message read by readBy (always this store's own identity -- see the auto-mark-read timers in fireLocalDelivery and drainDelivery, its only callers) and notifies the message's own author via a directed, wire-authenticated room.read (P3.5), replacing the legacy message_read patch's mesh-wide broadcast: only the author is a genuine audience for "did you read my message" per core/room's own always-voluntary read-receipt design, and a direct request to them is strictly more than the old broadcast actually needed. Silently does nothing beyond the local readBy update when the author is this store itself (a self-DM, or an already-own message) or when this store holds no room:member token for the message's own room-path -- read receipts stay best-effort by design, never a reason to throw.
|
|
834
|
-
*/
|
|
835
|
-
async markRead(messageId, readBy, room) {
|
|
836
|
-
const location = this.findMessageLocation(messageId, room);
|
|
837
|
-
if (location === undefined)
|
|
838
|
-
return;
|
|
839
|
-
const { roomPath, from } = location;
|
|
840
|
-
const history = room !== undefined ? this.messages.get(room) : this.dms.get(roomPath);
|
|
841
|
-
const message = history?.find((m) => m.id === messageId);
|
|
842
|
-
if (message && !message.readBy.includes(readBy)) {
|
|
843
|
-
message.readBy.push(readBy);
|
|
844
|
-
}
|
|
845
|
-
if (from === readBy)
|
|
846
|
-
return;
|
|
847
|
-
const { slot, clock } = this.requireIdentity();
|
|
848
|
-
const token = loadRoomTokens(slot)[roomPath];
|
|
849
|
-
if (token === undefined)
|
|
850
|
-
return;
|
|
851
|
-
const params = {
|
|
852
|
-
verb: "room.read",
|
|
853
|
-
messages: [bytesFromHex(messageId)],
|
|
854
|
-
at: clock.now(),
|
|
855
|
-
};
|
|
856
|
-
await this.sendRoomRequestToMember(from, roomPath, token, params);
|
|
306
|
+
get roomVerbHandlers() {
|
|
307
|
+
return this.roomProtocol.roomVerbHandlers;
|
|
857
308
|
}
|
|
858
309
|
// -----------------------------------------------------------------------
|
|
859
310
|
// CommsStore — Identity
|
|
860
311
|
// -----------------------------------------------------------------------
|
|
861
312
|
async readIdentity(harness, cwd) {
|
|
862
|
-
|
|
863
|
-
return this.identityCache.get(`${harness}--${cwd}`);
|
|
313
|
+
return this.agentRegistry.readIdentity(harness, cwd);
|
|
864
314
|
}
|
|
865
315
|
async writeIdentity(harness, cwd, id) {
|
|
866
|
-
|
|
867
|
-
this.identityCache.set(`${harness}--${cwd}`, { id });
|
|
316
|
+
return this.agentRegistry.writeIdentity(harness, cwd, id);
|
|
868
317
|
}
|
|
869
318
|
// -----------------------------------------------------------------------
|
|
870
319
|
// CommsStore — Agent registry
|
|
871
320
|
// -----------------------------------------------------------------------
|
|
872
321
|
async registerAgent(opts) {
|
|
873
|
-
|
|
874
|
-
if (existing) {
|
|
875
|
-
return this.updateAgent(existing.id, {
|
|
876
|
-
name: opts.name,
|
|
877
|
-
visibility: opts.visibility,
|
|
878
|
-
tags: opts.tags,
|
|
879
|
-
status: "active",
|
|
880
|
-
pid: opts.pid,
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
const id = this.peerId;
|
|
884
|
-
const agent = {
|
|
885
|
-
id,
|
|
886
|
-
version: 1,
|
|
887
|
-
name: opts.name,
|
|
888
|
-
harness: opts.harness,
|
|
889
|
-
cwd: opts.cwd,
|
|
890
|
-
pid: opts.pid,
|
|
891
|
-
startedAt: this.startedAt,
|
|
892
|
-
visibility: opts.visibility,
|
|
893
|
-
status: "active",
|
|
894
|
-
tags: opts.tags,
|
|
895
|
-
subscribedRooms: [],
|
|
896
|
-
};
|
|
897
|
-
this.agents.set(id, agent);
|
|
898
|
-
await this.writeIdentity(opts.harness, opts.cwd, id);
|
|
899
|
-
await this.broadcastPatch({ type: "agent_upsert", agent });
|
|
900
|
-
// Broadcast presence to federated links
|
|
901
|
-
if (agent.visibility === "visible") {
|
|
902
|
-
await this.federation.broadcastAgentVisible(agent);
|
|
903
|
-
}
|
|
904
|
-
return agent;
|
|
322
|
+
return this.agentRegistry.registerAgent(opts);
|
|
905
323
|
}
|
|
906
324
|
async getAgent(id) {
|
|
907
|
-
|
|
908
|
-
return this.agents.get(id);
|
|
325
|
+
return this.agentRegistry.getAgent(id);
|
|
909
326
|
}
|
|
910
327
|
async updateAgent(id, patch) {
|
|
911
|
-
|
|
912
|
-
if (!agent)
|
|
913
|
-
throw new CommsError(`Agent ${id} not found`, "AGENT_NOT_FOUND");
|
|
914
|
-
const oldStatus = agent.status;
|
|
915
|
-
const oldName = agent.name;
|
|
916
|
-
Object.assign(agent, patch);
|
|
917
|
-
this.bump(agent);
|
|
918
|
-
this.agents.set(id, agent);
|
|
919
|
-
await this.broadcastPatch({ type: "agent_upsert", agent });
|
|
920
|
-
if (patch.name !== undefined && patch.name !== oldName) {
|
|
921
|
-
await this.notifyRoomsOfNameChange(id, oldName, patch.name);
|
|
922
|
-
}
|
|
923
|
-
if (patch.status && patch.status !== oldStatus) {
|
|
924
|
-
await this.notifyRoomsOfStatus(id, patch.status);
|
|
925
|
-
}
|
|
926
|
-
return agent;
|
|
328
|
+
return this.agentRegistry.updateAgent(id, patch);
|
|
927
329
|
}
|
|
928
330
|
async listAgents(requesterId) {
|
|
929
|
-
|
|
930
|
-
const result = [];
|
|
931
|
-
for (const agent of this.agents.values()) {
|
|
932
|
-
if (agent.visibility === "ghost" && agent.id !== requesterId)
|
|
933
|
-
continue;
|
|
934
|
-
result.push(agent);
|
|
935
|
-
}
|
|
936
|
-
return result;
|
|
331
|
+
return this.agentRegistry.listAgents(requesterId);
|
|
937
332
|
}
|
|
938
333
|
async setAgentOffline(id) {
|
|
939
|
-
|
|
940
|
-
if (!agent)
|
|
941
|
-
return;
|
|
942
|
-
if (agent.status === "offline")
|
|
943
|
-
return;
|
|
944
|
-
// Only the owning store should broadcast the status change.
|
|
945
|
-
// Other stores learn about it via the agent_offline mesh patch.
|
|
946
|
-
const isOwner = id === this.peerId;
|
|
947
|
-
agent.status = "offline";
|
|
948
|
-
this.bump(agent);
|
|
949
|
-
this.agents.set(id, agent);
|
|
950
|
-
if (isOwner) {
|
|
951
|
-
await this.notifyRoomsOfStatus(id, "offline");
|
|
952
|
-
await this.broadcastPatch({ type: "agent_offline", agentId: id });
|
|
953
|
-
await this.federation.broadcastAgentGone(id);
|
|
954
|
-
}
|
|
334
|
+
return this.agentRegistry.setAgentOffline(id);
|
|
955
335
|
}
|
|
956
336
|
// -----------------------------------------------------------------------
|
|
957
337
|
// CommsStore — Rooms
|
|
958
338
|
// -----------------------------------------------------------------------
|
|
959
|
-
/**
|
|
960
|
-
* Mints and persists the room owner's own self-signed room:member grant: issuer = bearer = owner, no parent, delegationsRemaining: 0. This is deliberately NOT the parent every later member grant chains through -- a delegations-remaining: 0 parent cannot mint any child at all (mintCapabilityToken refuses a child whose own delegationsRemaining isn't strictly less than its parent's, and there is no value less than 0), so a later join/invite grant is its own independent, parent-less, owner-issued root-level token instead (still satisfying the "chain roots at the path's own owner" obligation, since rootIssuer is just the token's own issuer when it carries no parent). This root grant exists purely so the owner has a token to present for its own room actions, uniformly with every other member, per the design's own "every code path that checks membership does the same thing regardless of who it is checking" reasoning.
|
|
961
|
-
*/
|
|
962
|
-
async mintOwnerRootGrant(roomPath, owner) {
|
|
963
|
-
const { identity, clock, slot } = this.requireIdentity();
|
|
964
|
-
const verdict = await mintCapabilityToken({
|
|
965
|
-
identity,
|
|
966
|
-
clock,
|
|
967
|
-
tokenId: randomId(),
|
|
968
|
-
bearer: deviceIdFromHex(owner),
|
|
969
|
-
capability: "room:member",
|
|
970
|
-
scope: { kind: "room", path: roomPath },
|
|
971
|
-
expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
|
|
972
|
-
delegationsRemaining: 0,
|
|
973
|
-
});
|
|
974
|
-
if (!verdict.ok) {
|
|
975
|
-
throw new Error(`MeshStore: failed to mint room owner grant for ${roomPath}: ${verdict.reason}`);
|
|
976
|
-
}
|
|
977
|
-
saveRoomToken(slot, roomPath, verdict.token);
|
|
978
|
-
}
|
|
979
|
-
/**
|
|
980
|
-
* Room verb handlers this store registers with its own WireMeshTransport, keyed by params.verb per room-router.ts's own dispatch discipline. A getter (not a plain field) so every access reads through the current `this` binding without needing a constructor-time closure -- mirrors the `events` getter's own reasoning below.
|
|
981
|
-
*/
|
|
982
|
-
get roomVerbHandlers() {
|
|
983
|
-
return {
|
|
984
|
-
"room.join": (request, handle) => this.handleRoomJoin(request, handle),
|
|
985
|
-
"room.send": (request, handle) => this.handleRoomSend(request, handle),
|
|
986
|
-
"room.read": (request, handle) => this.handleRoomRead(request, handle),
|
|
987
|
-
"room.members": (request, handle) => this.handleRoomMembers(request, handle),
|
|
988
|
-
"room.invite": (request) => this.handleRoomInvite(request),
|
|
989
|
-
"room.leave": (request, handle) => this.handleRoomLeave(request, handle),
|
|
990
|
-
};
|
|
991
|
-
}
|
|
992
|
-
/** Reads the "reply" message-ref out of a room.send's own params (if any) and returns the hex id it names -- room.send's replyTo carries a single parent message, so the first reply-relation ref is the one that matters; any further refs are a future relation this handler doesn't yet act on. */
|
|
993
|
-
static replyToFromRefs(refs) {
|
|
994
|
-
const reply = refs?.find((ref) => ref.relation === "reply");
|
|
995
|
-
return reply === undefined ? undefined : bytesToHex(reply.id);
|
|
996
|
-
}
|
|
997
|
-
/** Reads room.send's own "streaming-behavior" extension field (open params tail, not a named schema field) and validates it against the same StreamingBehavior contract every other delivery path already enforces -- an unrecognised or malformed value is dropped rather than rejecting the whole send, matching core/room's own obligation to ignore what it doesn't understand instead of failing closed on an extension field. */
|
|
998
|
-
static streamingBehaviorFromParams(params) {
|
|
999
|
-
const raw = params["streaming-behavior"];
|
|
1000
|
-
if (raw === undefined)
|
|
1001
|
-
return undefined;
|
|
1002
|
-
const result = StreamingBehavior.safeParse(raw);
|
|
1003
|
-
return result.success ? result.data : undefined;
|
|
1004
|
-
}
|
|
1005
|
-
/**
|
|
1006
|
-
* Receiving side of a directed room.send (P3.5): verifies the presented token against all six of core/room's own obligations, then delivers the message locally exactly once -- the manage-response this returns IS the delivery receipt, so there is no separate "delivered" event to emit the way the legacy broadcastPatch path needed one. Branches on the room-path's own shape: an owner-named path stores a RoomMessage in this room's own history and fires a room_message event; a DM path stores a DmMessage keyed by the same dm-path sendDm already uses and fires a dm event -- both ride the identical room:member-gated verb, since a DM is just a room-path variant, not a separate verb.
|
|
1007
|
-
*/
|
|
1008
|
-
async handleRoomSend(request, handle) {
|
|
1009
|
-
const roomPath = request.scope.path;
|
|
1010
|
-
if (roomPath === undefined) {
|
|
1011
|
-
return { result: "error", code: "missing_scope_path" };
|
|
1012
|
-
}
|
|
1013
|
-
if (request.token === undefined) {
|
|
1014
|
-
return { result: "error", code: "unauthorized" };
|
|
1015
|
-
}
|
|
1016
|
-
const { identity, clock, revocation } = this.requireIdentity();
|
|
1017
|
-
const verdict = await verifyRoomToken(request.token, {
|
|
1018
|
-
identity,
|
|
1019
|
-
clock,
|
|
1020
|
-
revocation,
|
|
1021
|
-
expectedBearer: deviceIdFromHex(handle.id),
|
|
1022
|
-
roomPath,
|
|
1023
|
-
});
|
|
1024
|
-
if (!verdict.ok) {
|
|
1025
|
-
return { result: "error", code: "unauthorized" };
|
|
1026
|
-
}
|
|
1027
|
-
const parsedParams = roomSendSchema.safeParse(request.command.params);
|
|
1028
|
-
if (!parsedParams.success) {
|
|
1029
|
-
return { result: "error", code: "malformed_params" };
|
|
1030
|
-
}
|
|
1031
|
-
const params = parsedParams.data;
|
|
1032
|
-
const replyTo = MeshStore.replyToFromRefs(params.refs);
|
|
1033
|
-
const streamingBehavior = MeshStore.streamingBehaviorFromParams(params);
|
|
1034
|
-
const id = bytesToHex(params["message-id"]);
|
|
1035
|
-
const timestamp = new Date(params["sent-at"]).toISOString();
|
|
1036
|
-
const parsedPath = parseRoomPath(roomPath);
|
|
1037
|
-
let event;
|
|
1038
|
-
if (parsedPath.kind === "dm") {
|
|
1039
|
-
const message = {
|
|
1040
|
-
id,
|
|
1041
|
-
from: handle.id,
|
|
1042
|
-
to: this.peerId,
|
|
1043
|
-
content: params.text,
|
|
1044
|
-
timestamp,
|
|
1045
|
-
readBy: [handle.id],
|
|
1046
|
-
...(streamingBehavior !== undefined && { streamingBehavior }),
|
|
1047
|
-
};
|
|
1048
|
-
const history = this.dms.get(roomPath) ?? [];
|
|
1049
|
-
history.push(message);
|
|
1050
|
-
this.dms.set(roomPath, history);
|
|
1051
|
-
event = { type: "dm", message };
|
|
1052
|
-
}
|
|
1053
|
-
else {
|
|
1054
|
-
const message = {
|
|
1055
|
-
id,
|
|
1056
|
-
from: handle.id,
|
|
1057
|
-
room: roomPath,
|
|
1058
|
-
content: params.text,
|
|
1059
|
-
timestamp,
|
|
1060
|
-
readBy: [handle.id],
|
|
1061
|
-
...(replyTo !== undefined && { replyTo }),
|
|
1062
|
-
...(streamingBehavior !== undefined && { streamingBehavior }),
|
|
1063
|
-
};
|
|
1064
|
-
const history = this.messages.get(roomPath) ?? [];
|
|
1065
|
-
history.push(message);
|
|
1066
|
-
this.messages.set(roomPath, history);
|
|
1067
|
-
event = { type: "room_message", message };
|
|
1068
|
-
}
|
|
1069
|
-
this.queueDelivery(this.peerId, event);
|
|
1070
|
-
this.fireLocalDelivery(this.peerId, event);
|
|
1071
|
-
return { result: "ok" };
|
|
1072
|
-
}
|
|
1073
|
-
/**
|
|
1074
|
-
* Receiving side of a directed room.read (P3.5): verifies the presented token the same way handleRoomSend does, then for each read message-id in the batch, updates this store's own local copy of that message's readBy (this store holds one because it's the message's own author -- the reason it's the one being notified) and fires a delivery_status event locally, replacing what markRead used to broadcast via the legacy message_read patch. Read receipts stay voluntary and best-effort by design: a message-id this store doesn't recognise (already expired from history, or simply never this store's own) is silently skipped rather than treated as an error.
|
|
1075
|
-
*/
|
|
1076
|
-
async handleRoomRead(request, handle) {
|
|
1077
|
-
const roomPath = request.scope.path;
|
|
1078
|
-
if (roomPath === undefined) {
|
|
1079
|
-
return { result: "error", code: "missing_scope_path" };
|
|
1080
|
-
}
|
|
1081
|
-
if (request.token === undefined) {
|
|
1082
|
-
return { result: "error", code: "unauthorized" };
|
|
1083
|
-
}
|
|
1084
|
-
const { identity, clock, revocation } = this.requireIdentity();
|
|
1085
|
-
const verdict = await verifyRoomToken(request.token, {
|
|
1086
|
-
identity,
|
|
1087
|
-
clock,
|
|
1088
|
-
revocation,
|
|
1089
|
-
expectedBearer: deviceIdFromHex(handle.id),
|
|
1090
|
-
roomPath,
|
|
1091
|
-
});
|
|
1092
|
-
if (!verdict.ok) {
|
|
1093
|
-
return { result: "error", code: "unauthorized" };
|
|
1094
|
-
}
|
|
1095
|
-
const parsedParams = roomReadSchema.safeParse(request.command.params);
|
|
1096
|
-
if (!parsedParams.success) {
|
|
1097
|
-
return { result: "error", code: "malformed_params" };
|
|
1098
|
-
}
|
|
1099
|
-
const params = parsedParams.data;
|
|
1100
|
-
const isDm = parseRoomPath(roomPath).kind === "dm";
|
|
1101
|
-
for (const messageIdBytes of params.messages) {
|
|
1102
|
-
const messageId = bytesToHex(messageIdBytes);
|
|
1103
|
-
const history = isDm
|
|
1104
|
-
? this.dms.get(roomPath)
|
|
1105
|
-
: this.messages.get(roomPath);
|
|
1106
|
-
const message = history?.find((m) => m.id === messageId);
|
|
1107
|
-
if (message === undefined)
|
|
1108
|
-
continue;
|
|
1109
|
-
if (!message.readBy.includes(handle.id)) {
|
|
1110
|
-
message.readBy.push(handle.id);
|
|
1111
|
-
}
|
|
1112
|
-
const event = {
|
|
1113
|
-
type: "delivery_status",
|
|
1114
|
-
messageId,
|
|
1115
|
-
agent: handle.id,
|
|
1116
|
-
status: "read",
|
|
1117
|
-
...(isDm ? {} : { room: roomPath }),
|
|
1118
|
-
};
|
|
1119
|
-
this.queueDelivery(this.peerId, event);
|
|
1120
|
-
this.fireLocalDelivery(this.peerId, event);
|
|
1121
|
-
}
|
|
1122
|
-
return { result: "ok" };
|
|
1123
|
-
}
|
|
1124
|
-
/**
|
|
1125
|
-
* Receiving side of room.members (P3.6): a plain membership + room-state refresh for an already-admitted member, verified the same way handleRoomSend/handleRoomRead are -- room.members grants nothing new, it just answers "who's here, and what's this room called" on demand, the same information room.join's own response already carries at admission time. A DM path has no Room record (this.rooms never holds one for a dm-shaped path) and no name/description/type to report, so its own members list is derived directly from the path's own two participants instead.
|
|
1126
|
-
*/
|
|
1127
|
-
async handleRoomMembers(request, handle) {
|
|
1128
|
-
const roomPath = request.scope.path;
|
|
1129
|
-
if (roomPath === undefined) {
|
|
1130
|
-
return { result: "error", code: "missing_scope_path" };
|
|
1131
|
-
}
|
|
1132
|
-
if (request.token === undefined) {
|
|
1133
|
-
return { result: "error", code: "unauthorized" };
|
|
1134
|
-
}
|
|
1135
|
-
const { identity, clock, revocation } = this.requireIdentity();
|
|
1136
|
-
const verdict = await verifyRoomToken(request.token, {
|
|
1137
|
-
identity,
|
|
1138
|
-
clock,
|
|
1139
|
-
revocation,
|
|
1140
|
-
expectedBearer: deviceIdFromHex(handle.id),
|
|
1141
|
-
roomPath,
|
|
1142
|
-
});
|
|
1143
|
-
if (!verdict.ok) {
|
|
1144
|
-
return { result: "error", code: "unauthorized" };
|
|
1145
|
-
}
|
|
1146
|
-
const parsedPath = parseRoomPath(roomPath);
|
|
1147
|
-
const room = this.rooms.get(roomPath);
|
|
1148
|
-
const members = parsedPath.kind === "dm"
|
|
1149
|
-
? parsedPath.participants.map((device) => ({
|
|
1150
|
-
device: deviceIdFromHex(device),
|
|
1151
|
-
}))
|
|
1152
|
-
: (room?.members ?? []).map((memberId) => ({
|
|
1153
|
-
device: deviceIdFromHex(memberId),
|
|
1154
|
-
}));
|
|
1155
|
-
return {
|
|
1156
|
-
result: "ok",
|
|
1157
|
-
members,
|
|
1158
|
-
...MeshStore.roomStateExtension(room),
|
|
1159
|
-
};
|
|
1160
|
-
}
|
|
1161
|
-
/**
|
|
1162
|
-
* Sends one directed room.send to a single member's own session, attaching this store's own persisted room:member token for the given room path -- the primitive P3.5's own directed fan-out (deliverToRoom) will loop over per member once it replaces the legacy broadcastPatch path this store still uses for message delivery today. Throws if this store holds no token for the room: never a member, or a token that expired or was revoked with nothing fresh persisted in its place.
|
|
1163
|
-
*/
|
|
1164
|
-
async sendRoomMessageDirected(roomPath, memberId, text) {
|
|
1165
|
-
const { slot, clock } = this.requireIdentity();
|
|
1166
|
-
const token = loadRoomTokens(slot)[roomPath];
|
|
1167
|
-
if (token === undefined) {
|
|
1168
|
-
throw new CommsError(`No room:member token for ${roomPath}`, "NOT_A_MEMBER");
|
|
1169
|
-
}
|
|
1170
|
-
const outcome = await this.requireTransport().sendRoomRequest(memberId, {
|
|
1171
|
-
verb: ROOM_MEMBER_CAPABILITY,
|
|
1172
|
-
params: {
|
|
1173
|
-
verb: "room.send",
|
|
1174
|
-
"message-id": randomId(),
|
|
1175
|
-
"sent-at": clock.now(),
|
|
1176
|
-
text,
|
|
1177
|
-
},
|
|
1178
|
-
}, { kind: "room", path: roomPath }, token);
|
|
1179
|
-
if (outcome.result !== "ok") {
|
|
1180
|
-
throw new CommsError(`room.send to ${memberId} for ${roomPath} failed (${outcome.code})`, "SEND_FAILED");
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
/** Records a room-domain request that couldn't reach memberId right now, for a later flushPendingRoomRequests to retry once that member reconnects. Bounded oldest-first with the same cap ordinary delivery queues use, so an indefinitely-offline member cannot grow this without limit. */
|
|
1184
|
-
queuePendingRoomRequest(memberId, roomPath, params) {
|
|
1185
|
-
const queue = this.pendingRoomRequests.get(memberId) ?? [];
|
|
1186
|
-
queue.push({ roomPath, params });
|
|
1187
|
-
if (queue.length > MAX_QUEUED_DELIVERIES_PER_AGENT) {
|
|
1188
|
-
queue.splice(0, queue.length - MAX_QUEUED_DELIVERIES_PER_AGENT);
|
|
1189
|
-
}
|
|
1190
|
-
this.pendingRoomRequests.set(memberId, queue);
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Sends one directed room-domain request (room.send, room.read, or any future room:member-gated verb) to a single member, queuing it for retry instead of throwing when the member isn't currently reachable -- the fan-out's own per-recipient primitive, distinct from sendRoomMessageDirected's deliberate throw-on-failure contract for a caller sending to one specific, known recipient. Silently drops a request this store no longer holds a token for (no longer a member of the room) rather than queuing something that will only fail again on retry.
|
|
1194
|
-
*/
|
|
1195
|
-
async sendRoomRequestToMember(memberId, roomPath, token, params) {
|
|
1196
|
-
// sendRoomRequest can reject outright (e.g. the connection drops mid-request, per wire-mesh-core's own rejectPendingManageRequests), not just resolve with an error outcome -- both are exactly the same "memberId isn't reachable right now" fact from this method's own point of view, so both queue for retry rather than one of them propagating as an uncaught rejection out of what every caller treats as a fire-and-forget send.
|
|
1197
|
-
let outcome;
|
|
1198
|
-
try {
|
|
1199
|
-
outcome = await this.requireTransport().sendRoomRequest(memberId, { verb: ROOM_MEMBER_CAPABILITY, params }, { kind: "room", path: roomPath }, token);
|
|
1200
|
-
}
|
|
1201
|
-
catch {
|
|
1202
|
-
this.queuePendingRoomRequest(memberId, roomPath, params);
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1205
|
-
if (outcome.result !== "ok") {
|
|
1206
|
-
this.queuePendingRoomRequest(memberId, roomPath, params);
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
/** Retries every room.send queued for memberId since it was last reachable, dropping (not re-queuing) any whose room this store no longer holds a token for. Called once a connection to memberId is (re)established -- handlePeerConnected fires for both a fresh introduction and a reconnection after downtime, exactly the two cases a queued send needs to be retried on. */
|
|
1210
|
-
async flushPendingRoomRequests(memberId) {
|
|
1211
|
-
const queue = this.pendingRoomRequests.get(memberId);
|
|
1212
|
-
if (queue === undefined || queue.length === 0)
|
|
1213
|
-
return;
|
|
1214
|
-
this.pendingRoomRequests.delete(memberId);
|
|
1215
|
-
const { slot } = this.requireIdentity();
|
|
1216
|
-
for (const pending of queue) {
|
|
1217
|
-
const token = loadRoomTokens(slot)[pending.roomPath];
|
|
1218
|
-
if (token === undefined)
|
|
1219
|
-
continue;
|
|
1220
|
-
await this.sendRoomRequestToMember(memberId, pending.roomPath, token, pending.params);
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
/**
|
|
1224
|
-
* Owner-side admission for an incoming room.join request, against either a named room this store owns or a DM path this store is a participant of. Named-room admission always needs a human decision; DM admission needs one only for the party being contacted first -- the reply half of the two-round consent flow (section 6) auto-approves, since a reply on a path this node itself opened is not unsolicited contact.
|
|
1225
|
-
*/
|
|
1226
|
-
async handleRoomJoin(request, handle) {
|
|
1227
|
-
const roomPath = request.scope.path;
|
|
1228
|
-
if (roomPath === undefined) {
|
|
1229
|
-
return { result: "error", code: "missing_scope_path" };
|
|
1230
|
-
}
|
|
1231
|
-
const parsed = parseRoomPath(roomPath);
|
|
1232
|
-
if (parsed.kind === "owner-named") {
|
|
1233
|
-
if (parsed.owner !== this.peerId) {
|
|
1234
|
-
return { result: "error", code: "not_owner" };
|
|
1235
|
-
}
|
|
1236
|
-
return this.admitRoomJoin(roomPath, handle, false);
|
|
1237
|
-
}
|
|
1238
|
-
if (!parsed.participants.includes(this.peerId)) {
|
|
1239
|
-
return { result: "error", code: "not_participant" };
|
|
1240
|
-
}
|
|
1241
|
-
// The reciprocal half of section 6's own two-round DM flow: this node's own outbound room.join to the same path (recorded by joinRemoteRoom before this response was even awaited) is the consent that makes the counterpart's own reply not unsolicited contact.
|
|
1242
|
-
const autoApprove = this.dmRequestsInitiatedByMe.has(roomPath);
|
|
1243
|
-
return this.admitRoomJoin(roomPath, handle, autoApprove);
|
|
1244
|
-
}
|
|
1245
|
-
/** Shared admission continuation for both room.join branches above: waits for a human decision (unless auto-approved), then mints and returns the requester's own independent, parent-less room:member grant. */
|
|
1246
|
-
async admitRoomJoin(roomPath, handle, autoApprove) {
|
|
1247
|
-
const decision = autoApprove
|
|
1248
|
-
? { kind: "accept" }
|
|
1249
|
-
: await new Promise((resolve) => {
|
|
1250
|
-
this.pendingRoomJoins.set(`${roomPath}::${handle.id}`, {
|
|
1251
|
-
roomPath,
|
|
1252
|
-
requesterId: handle.id,
|
|
1253
|
-
resolve,
|
|
1254
|
-
});
|
|
1255
|
-
});
|
|
1256
|
-
if (!autoApprove)
|
|
1257
|
-
this.pendingRoomJoins.delete(`${roomPath}::${handle.id}`);
|
|
1258
|
-
if (decision.kind === "reject") {
|
|
1259
|
-
return {
|
|
1260
|
-
result: "error",
|
|
1261
|
-
code: "denied",
|
|
1262
|
-
...(decision.reason !== undefined ? { message: decision.reason } : {}),
|
|
1263
|
-
};
|
|
1264
|
-
}
|
|
1265
|
-
// The granted token itself belongs to the requester's own node, which persists it itself once it receives this response -- but this identity slot must also remember the token-id it just issued (saveIssuedRoomGrant below), since revoking a specific member's grant later (kickFromRoom) has no other way to name which token-id to revoke: a token-id is never presented back on the wire, so a room owner's own memory of having minted it is the only record.
|
|
1266
|
-
const { identity, clock, slot } = this.requireIdentity();
|
|
1267
|
-
const tokenId = randomId();
|
|
1268
|
-
const verdict = await mintCapabilityToken({
|
|
1269
|
-
identity,
|
|
1270
|
-
clock,
|
|
1271
|
-
tokenId,
|
|
1272
|
-
bearer: deviceIdFromHex(handle.id),
|
|
1273
|
-
capability: "room:member",
|
|
1274
|
-
scope: { kind: "room", path: roomPath },
|
|
1275
|
-
expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
|
|
1276
|
-
delegationsRemaining: 0,
|
|
1277
|
-
});
|
|
1278
|
-
if (!verdict.ok) {
|
|
1279
|
-
return { result: "error", code: "mint_failed" };
|
|
1280
|
-
}
|
|
1281
|
-
saveIssuedRoomGrant(slot, roomPath, handle.id, tokenId);
|
|
1282
|
-
const room = this.rooms.get(roomPath);
|
|
1283
|
-
if (room !== undefined) {
|
|
1284
|
-
this.bump(room);
|
|
1285
|
-
this.recordMemberOp(room, "member", "join", handle.id);
|
|
1286
|
-
this.refreshMembership(room);
|
|
1287
|
-
this.rooms.set(roomPath, room);
|
|
1288
|
-
}
|
|
1289
|
-
const members = (room?.members ?? [this.peerId, handle.id]).map((memberId) => ({ device: deviceIdFromHex(memberId) }));
|
|
1290
|
-
return {
|
|
1291
|
-
result: "ok",
|
|
1292
|
-
"granted-token": verdict.token,
|
|
1293
|
-
members,
|
|
1294
|
-
...MeshStore.roomStateExtension(room),
|
|
1295
|
-
};
|
|
1296
|
-
}
|
|
1297
|
-
/**
|
|
1298
|
-
* Room metadata (name/description/type) riding room-join-ok/room-members-ok's own open extension tail (P3.6), agent-comms' own convention for the "namespaced room-state extension" the design names -- core/room itself has no concept of a display name or description, so this is application data, not a wire-level field. Absent entirely for a DM path (room is undefined there; a DM has no name/description/type to report) rather than a hollow placeholder.
|
|
1299
|
-
*/
|
|
1300
|
-
static roomStateExtension(room) {
|
|
1301
|
-
if (room === undefined)
|
|
1302
|
-
return {};
|
|
1303
|
-
return {
|
|
1304
|
-
"room-state": {
|
|
1305
|
-
name: room.name,
|
|
1306
|
-
description: room.description,
|
|
1307
|
-
type: room.type,
|
|
1308
|
-
},
|
|
1309
|
-
};
|
|
1310
|
-
}
|
|
1311
|
-
/** The receiving-side counterpart of roomStateExtension: narrows an incoming response's own "room-state" field (an unknown, since it rides the wire schema's open catchall tail) to the shape this store's own convention actually sends, or undefined for a peer running without it (an older version, or a DM's own room-join-ok, which never carries one). */
|
|
1312
|
-
static parseRoomStateExtension(value) {
|
|
1313
|
-
if (typeof value !== "object" || value === null)
|
|
1314
|
-
return undefined;
|
|
1315
|
-
if (!("name" in value) || !("description" in value) || !("type" in value))
|
|
1316
|
-
return undefined;
|
|
1317
|
-
if (typeof value.name !== "string" || typeof value.description !== "string")
|
|
1318
|
-
return undefined;
|
|
1319
|
-
const parsedType = RoomType.safeParse(value.type);
|
|
1320
|
-
if (!parsedType.success)
|
|
1321
|
-
return undefined;
|
|
1322
|
-
return {
|
|
1323
|
-
name: value.name,
|
|
1324
|
-
description: value.description,
|
|
1325
|
-
type: parsedType.data,
|
|
1326
|
-
};
|
|
1327
|
-
}
|
|
1328
|
-
/**
|
|
1329
|
-
* The inviter's own display name and cwd, riding room-invite's own open extension tail -- agent-comms' own application data, the same convention roomStateExtension already establishes for room metadata. Unlike room-state (which needs gossip on the requester's own side of room.join/members), the inviter here is always this store's own local agent record: inviteToRoom only ever succeeds for the room's real owner, which per this store's own organising fact (one bridge is one agent is one device) is always this.peerId, so the record is always this store's own registration, never a gossip-dependent lookup.
|
|
1330
|
-
*/
|
|
1331
|
-
static inviterAgentExtension(inviter) {
|
|
1332
|
-
return { "inviter-agent": { name: inviter.name, cwd: inviter.cwd } };
|
|
1333
|
-
}
|
|
1334
|
-
/** The receiving-side counterpart of inviterAgentExtension: narrows an incoming room.invite's own "inviter-agent" field to the shape this store's own convention sends, or undefined for a peer running without it (an older version) -- the caller falls back to the inviter's bare device-id in that case, the same honest degradation parseRoomStateExtension's own absent case already accepts. */
|
|
1335
|
-
static parseInviterAgentExtension(value) {
|
|
1336
|
-
if (typeof value !== "object" || value === null)
|
|
1337
|
-
return undefined;
|
|
1338
|
-
if (!("name" in value) || !("cwd" in value))
|
|
1339
|
-
return undefined;
|
|
1340
|
-
if (typeof value.name !== "string" || typeof value.cwd !== "string")
|
|
1341
|
-
return undefined;
|
|
1342
|
-
return { name: value.name, cwd: value.cwd };
|
|
1343
|
-
}
|
|
1344
|
-
/**
|
|
1345
|
-
* Receiving side of a real, wire-level room.invite (P3.8): unlike every other room verb, the request itself is deliberately ungated (the sender already IS the room's own owner, with no need to prove capability to invite) -- the security instead lives entirely in the embedded params.token, which must genuinely name this store's own identity as bearer and root at the room path's own claimed owner. Persists the verified token via saveRoomToken (mirroring joinRemoteRoom's own persistence) and fires a local room_invite delivery event carrying the room's real name/description (room-state) and the inviter's real name/cwd (inviter-agent) when the sender includes them, falling back to the bare device-id and room path otherwise.
|
|
1346
|
-
*/
|
|
1347
|
-
async handleRoomInvite(request) {
|
|
1348
|
-
const roomPath = request.scope.path;
|
|
1349
|
-
if (roomPath === undefined) {
|
|
1350
|
-
return { result: "error", code: "missing_scope_path" };
|
|
1351
|
-
}
|
|
1352
|
-
const parsedParams = roomInviteSchema.safeParse(request.command.params);
|
|
1353
|
-
if (!parsedParams.success) {
|
|
1354
|
-
return { result: "error", code: "malformed_params" };
|
|
1355
|
-
}
|
|
1356
|
-
const { token } = parsedParams.data;
|
|
1357
|
-
const { identity, clock, slot, revocation } = this.requireIdentity();
|
|
1358
|
-
const verdict = await verifyRoomToken(token, {
|
|
1359
|
-
identity,
|
|
1360
|
-
clock,
|
|
1361
|
-
revocation,
|
|
1362
|
-
expectedBearer: deviceIdFromHex(this.peerId),
|
|
1363
|
-
roomPath,
|
|
1364
|
-
});
|
|
1365
|
-
if (!verdict.ok) {
|
|
1366
|
-
return { result: "error", code: "unauthorized" };
|
|
1367
|
-
}
|
|
1368
|
-
saveRoomToken(slot, roomPath, token);
|
|
1369
|
-
const parsed = parseRoomPath(roomPath);
|
|
1370
|
-
const inviterId = parsed.kind === "owner-named" ? parsed.owner : this.peerId;
|
|
1371
|
-
const roomState = MeshStore.parseRoomStateExtension(parsedParams.data["room-state"]);
|
|
1372
|
-
const inviterAgent = MeshStore.parseInviterAgentExtension(parsedParams.data["inviter-agent"]);
|
|
1373
|
-
const event = {
|
|
1374
|
-
type: "room_invite",
|
|
1375
|
-
room: roomPath,
|
|
1376
|
-
roomDescription: roomState?.description ?? "",
|
|
1377
|
-
from: inviterId,
|
|
1378
|
-
fromName: inviterAgent?.name ?? inviterId,
|
|
1379
|
-
fromCwd: inviterAgent?.cwd ?? "",
|
|
1380
|
-
};
|
|
1381
|
-
this.queueDelivery(this.peerId, event);
|
|
1382
|
-
this.fireLocalDelivery(this.peerId, event);
|
|
1383
|
-
return { result: "ok" };
|
|
1384
|
-
}
|
|
1385
|
-
/**
|
|
1386
|
-
* Receiving side of a real, wire-level room.leave (P3.8), covering both an actual member leaving and a decline of a never-joined invite -- the same wire request either way, since both are "give up a room:member grant I hold," per leaveRemoteRoom's own reasoning. Distinguishes the two purely from this store's own membership/invited lists (never from anything the sender claims), revokes the sender's grant for real via revokeMemberGrant, and notifies accordingly: member_left broadcast to the room's other members for a real leave, a local invite_declined event (carrying the sender's own optional reason extension) for a decline -- there is no third party to notify for a decline, since nobody else ever knew about an invite that was never accepted.
|
|
1387
|
-
*/
|
|
1388
|
-
async handleRoomLeave(request, handle) {
|
|
1389
|
-
const roomPath = request.scope.path;
|
|
1390
|
-
if (roomPath === undefined) {
|
|
1391
|
-
return { result: "error", code: "missing_scope_path" };
|
|
1392
|
-
}
|
|
1393
|
-
if (request.token === undefined) {
|
|
1394
|
-
return { result: "error", code: "unauthorized" };
|
|
1395
|
-
}
|
|
1396
|
-
const { identity, clock, revocation } = this.requireIdentity();
|
|
1397
|
-
const verdict = await verifyRoomToken(request.token, {
|
|
1398
|
-
identity,
|
|
1399
|
-
clock,
|
|
1400
|
-
revocation,
|
|
1401
|
-
expectedBearer: deviceIdFromHex(handle.id),
|
|
1402
|
-
roomPath,
|
|
1403
|
-
});
|
|
1404
|
-
if (!verdict.ok) {
|
|
1405
|
-
return { result: "error", code: "unauthorized" };
|
|
1406
|
-
}
|
|
1407
|
-
const parsedParams = roomLeaveSchema.safeParse(request.command.params);
|
|
1408
|
-
if (!parsedParams.success) {
|
|
1409
|
-
return { result: "error", code: "malformed_params" };
|
|
1410
|
-
}
|
|
1411
|
-
const room = this.rooms.get(roomPath);
|
|
1412
|
-
if (room === undefined) {
|
|
1413
|
-
return { result: "error", code: "room_not_found" };
|
|
1414
|
-
}
|
|
1415
|
-
const wasMember = room.members.includes(handle.id);
|
|
1416
|
-
const wasInvited = room.invited.includes(handle.id);
|
|
1417
|
-
if (!wasMember && !wasInvited) {
|
|
1418
|
-
return { result: "ok" };
|
|
1419
|
-
}
|
|
1420
|
-
await this.revokeMemberGrant(roomPath, handle.id);
|
|
1421
|
-
this.bump(room);
|
|
1422
|
-
if (wasMember)
|
|
1423
|
-
this.recordMemberOp(room, "member", "leave", handle.id);
|
|
1424
|
-
if (wasInvited)
|
|
1425
|
-
this.recordMemberOp(room, "invited", "leave", handle.id);
|
|
1426
|
-
this.refreshMembership(room);
|
|
1427
|
-
this.rooms.set(roomPath, room);
|
|
1428
|
-
await this.broadcastPatch({ type: "room_upsert", room });
|
|
1429
|
-
if (wasMember) {
|
|
1430
|
-
await this.deliverToRoom(roomPath, { type: "member_left", room: roomPath, agent: handle.id }, handle.id);
|
|
1431
|
-
}
|
|
1432
|
-
else {
|
|
1433
|
-
const reasonValue = parsedParams.data.reason;
|
|
1434
|
-
const decliner = this.agents.get(handle.id);
|
|
1435
|
-
const event = {
|
|
1436
|
-
type: "invite_declined",
|
|
1437
|
-
room: roomPath,
|
|
1438
|
-
agent: handle.id,
|
|
1439
|
-
agentName: decliner?.name ?? handle.id,
|
|
1440
|
-
reason: typeof reasonValue === "string" ? reasonValue : "",
|
|
1441
|
-
};
|
|
1442
|
-
this.queueDelivery(this.peerId, event);
|
|
1443
|
-
this.fireLocalDelivery(this.peerId, event);
|
|
1444
|
-
}
|
|
1445
|
-
return { result: "ok" };
|
|
1446
|
-
}
|
|
1447
|
-
/** Every room.join request currently held open awaiting this store's own accept/reject decision. */
|
|
1448
|
-
listPendingRoomJoins() {
|
|
1449
|
-
return [...this.pendingRoomJoins.values()].map(({ roomPath, requesterId }) => ({ roomPath, requesterId }));
|
|
1450
|
-
}
|
|
1451
|
-
/** Approves a pending room.join request, resuming handleRoomJoin's own suspended mint-and-respond continuation. */
|
|
1452
|
-
acceptRoomJoin(roomPath, requesterId) {
|
|
1453
|
-
const key = `${roomPath}::${requesterId}`;
|
|
1454
|
-
const pending = this.pendingRoomJoins.get(key);
|
|
1455
|
-
if (pending === undefined) {
|
|
1456
|
-
throw new CommsError(`No pending room.join for ${requesterId} on ${roomPath}`, "NOT_PENDING");
|
|
1457
|
-
}
|
|
1458
|
-
pending.resolve({ kind: "accept" });
|
|
1459
|
-
}
|
|
1460
|
-
/** Denies a pending room.join request, optionally with a reason surfaced to the requester in the resulting manage-error's own message field. */
|
|
1461
|
-
rejectRoomJoin(roomPath, requesterId, reason) {
|
|
1462
|
-
const key = `${roomPath}::${requesterId}`;
|
|
1463
|
-
const pending = this.pendingRoomJoins.get(key);
|
|
1464
|
-
if (pending === undefined) {
|
|
1465
|
-
throw new CommsError(`No pending room.join for ${requesterId} on ${roomPath}`, "NOT_PENDING");
|
|
1466
|
-
}
|
|
1467
|
-
pending.resolve({
|
|
1468
|
-
kind: "reject",
|
|
1469
|
-
...(reason !== undefined ? { reason } : {}),
|
|
1470
|
-
});
|
|
1471
|
-
}
|
|
1472
339
|
async createRoom(opts) {
|
|
1473
|
-
|
|
1474
|
-
const slugName = slugRoomName(opts.name);
|
|
1475
|
-
const localName = opts.type === "secret" ? `_${slugName}` : slugName;
|
|
1476
|
-
const id = ownerNamedRoomPath(opts.owner, localName);
|
|
1477
|
-
if (this.rooms.has(id))
|
|
1478
|
-
throw new CommsError(`Room ${id} already exists`, "ROOM_EXISTS");
|
|
1479
|
-
// Every room creation this store performs is local: opts.owner is always this bridge's own identity (create_room's caller passes ctx.agentId, and one bridge process is one agent is one device), so minting the owner's own root grant here always signs under the identity this store was wired with, never someone else's.
|
|
1480
|
-
await this.mintOwnerRootGrant(id, opts.owner);
|
|
1481
|
-
const room = {
|
|
1482
|
-
id,
|
|
1483
|
-
version: 1,
|
|
1484
|
-
name: slugName,
|
|
1485
|
-
type: opts.type,
|
|
1486
|
-
owner: opts.owner,
|
|
1487
|
-
createdAt: new Date().toISOString(),
|
|
1488
|
-
description: opts.description,
|
|
1489
|
-
members: [opts.owner],
|
|
1490
|
-
invited: [],
|
|
1491
|
-
memberJoins: { [opts.owner]: 1 },
|
|
1492
|
-
memberLeaves: {},
|
|
1493
|
-
invitedJoins: {},
|
|
1494
|
-
invitedLeaves: {},
|
|
1495
|
-
federated: opts.federated ?? false,
|
|
1496
|
-
};
|
|
1497
|
-
this.rooms.set(id, room);
|
|
1498
|
-
this.messages.set(id, []);
|
|
1499
|
-
await this.broadcastPatch({ type: "room_upsert", room });
|
|
1500
|
-
return room;
|
|
340
|
+
return this.roomLifecycle.createRoom(opts);
|
|
1501
341
|
}
|
|
1502
342
|
async getRoom(id) {
|
|
1503
|
-
|
|
1504
|
-
return this.rooms.get(id);
|
|
343
|
+
return this.roomLifecycle.getRoom(id);
|
|
1505
344
|
}
|
|
1506
345
|
async listRooms(requesterId) {
|
|
1507
|
-
|
|
1508
|
-
const result = [];
|
|
1509
|
-
for (const room of this.rooms.values()) {
|
|
1510
|
-
if (room.type === "secret" && !room.members.includes(requesterId))
|
|
1511
|
-
continue;
|
|
1512
|
-
result.push(room);
|
|
1513
|
-
}
|
|
1514
|
-
return result;
|
|
346
|
+
return this.roomLifecycle.listRooms(requesterId);
|
|
1515
347
|
}
|
|
1516
|
-
/**
|
|
1517
|
-
* The remote-join path: roomPath names an owner-named room this store has never seen replicated, so joining it means sending a real wire-level room.join request to the room's own owner and persisting whatever grant comes back, rather than mutating already-known local state. Only ever called for this store's own local agent (one bridge is one agent is one device, per the design's own organising fact) -- there is no wire mechanism by which this node could join a room on a different local agent's behalf.
|
|
1518
|
-
*/
|
|
1519
|
-
async joinRemoteRoom(roomPath, agentId) {
|
|
1520
|
-
if (agentId !== this.peerId) {
|
|
1521
|
-
throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
|
|
1522
|
-
}
|
|
1523
|
-
const parsed = parseRoomPath(roomPath);
|
|
1524
|
-
if (parsed.kind !== "owner-named") {
|
|
1525
|
-
throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
|
|
1526
|
-
}
|
|
1527
|
-
const outcome = await this.requireTransport().sendRoomRequest(parsed.owner, { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.join" } }, { kind: "room", path: roomPath });
|
|
1528
|
-
if (outcome.result !== "ok") {
|
|
1529
|
-
throw new CommsError(`Join request for ${roomPath} was refused (${outcome.code})`, "JOIN_REFUSED");
|
|
1530
|
-
}
|
|
1531
|
-
const parsedOutcome = roomJoinOkSchema.safeParse(outcome);
|
|
1532
|
-
if (!parsedOutcome.success) {
|
|
1533
|
-
throw new CommsError(`Join response for ${roomPath} was malformed`, "MALFORMED_RESPONSE");
|
|
1534
|
-
}
|
|
1535
|
-
const { "granted-token": grantedToken, members: memberList } = parsedOutcome.data;
|
|
1536
|
-
const { slot } = this.requireIdentity();
|
|
1537
|
-
saveRoomToken(slot, roomPath, grantedToken);
|
|
1538
|
-
const members = memberList.map((member) => deviceIdToHex(member.device));
|
|
1539
|
-
const roomState = MeshStore.parseRoomStateExtension(parsedOutcome.data["room-state"]);
|
|
1540
|
-
const room = {
|
|
1541
|
-
id: roomPath,
|
|
1542
|
-
version: 1,
|
|
1543
|
-
name: roomState?.name ?? parsed.localName,
|
|
1544
|
-
// Falls back to "public" only against a peer running without the room-state extension (an older version); this joiner is always in `members` by construction regardless, so a stale "public" classification here never hides the room from its own member -- the one place type is read (listRooms' secret-room filter).
|
|
1545
|
-
type: roomState?.type ?? "public",
|
|
1546
|
-
owner: parsed.owner,
|
|
1547
|
-
createdAt: new Date().toISOString(),
|
|
1548
|
-
description: roomState?.description ?? "",
|
|
1549
|
-
members,
|
|
1550
|
-
invited: [],
|
|
1551
|
-
memberJoins: Object.fromEntries(members.map((member) => [member, 1])),
|
|
1552
|
-
memberLeaves: {},
|
|
1553
|
-
invitedJoins: {},
|
|
1554
|
-
invitedLeaves: {},
|
|
1555
|
-
federated: false,
|
|
1556
|
-
};
|
|
1557
|
-
this.rooms.set(roomPath, room);
|
|
1558
|
-
this.messages.set(roomPath, []);
|
|
1559
|
-
return room;
|
|
1560
|
-
}
|
|
1561
|
-
/**
|
|
1562
|
-
* Refreshes this store's own local copy of a named room's membership and room-state via a real room.members request (P3.6): the same information room.join's own response carries at admission time, available on demand for a member whose local copy may have drifted (a kick, an invite, a rename since it joined). Sent to the room's own owner, the authoritative source for that room's real state. Named rooms only, matching joinRemoteRoom's own restriction -- a DM's "membership" is already fully known from the path itself (the sorted pair of exactly two participants), and this codebase has no Room-object representation for a DM to refresh into (DM state lives in this.dms, keyed by message history, not this.rooms). Throws if this store holds no room:member token for roomPath -- refreshing membership presupposes already being a member, the same NOT_A_MEMBER contract sendRoomMessageDirected already uses.
|
|
1563
|
-
*/
|
|
348
|
+
/** Refreshes this store's own local copy of a named room's membership and room-state via a real room.members request (P3.6). Concrete-only -- reached directly by tests. */
|
|
1564
349
|
async refreshRoomMembers(roomPath) {
|
|
1565
|
-
|
|
1566
|
-
if (parsed.kind !== "owner-named") {
|
|
1567
|
-
throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
|
|
1568
|
-
}
|
|
1569
|
-
const { slot } = this.requireIdentity();
|
|
1570
|
-
const token = loadRoomTokens(slot)[roomPath];
|
|
1571
|
-
if (token === undefined) {
|
|
1572
|
-
throw new CommsError(`No room:member token for ${roomPath}`, "NOT_MEMBER");
|
|
1573
|
-
}
|
|
1574
|
-
const outcome = await this.requireTransport().sendRoomRequest(parsed.owner, { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.members" } }, { kind: "room", path: roomPath }, token);
|
|
1575
|
-
if (outcome.result !== "ok") {
|
|
1576
|
-
throw new CommsError(`room.members refresh for ${roomPath} failed (${outcome.code})`, "REFRESH_FAILED");
|
|
1577
|
-
}
|
|
1578
|
-
const parsedOutcome = roomMembersOkSchema.safeParse(outcome);
|
|
1579
|
-
if (!parsedOutcome.success) {
|
|
1580
|
-
throw new CommsError(`room.members response for ${roomPath} was malformed`, "MALFORMED_RESPONSE");
|
|
1581
|
-
}
|
|
1582
|
-
const members = parsedOutcome.data.members.map((member) => deviceIdToHex(member.device));
|
|
1583
|
-
const roomState = MeshStore.parseRoomStateExtension(parsedOutcome.data["room-state"]);
|
|
1584
|
-
const existing = this.rooms.get(roomPath);
|
|
1585
|
-
const room = {
|
|
1586
|
-
id: roomPath,
|
|
1587
|
-
version: (existing?.version ?? 0) + 1,
|
|
1588
|
-
name: roomState?.name ?? existing?.name ?? parsed.localName,
|
|
1589
|
-
type: roomState?.type ?? existing?.type ?? "public",
|
|
1590
|
-
owner: parsed.owner,
|
|
1591
|
-
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
1592
|
-
description: roomState?.description ?? existing?.description ?? "",
|
|
1593
|
-
members,
|
|
1594
|
-
invited: existing?.invited ?? [],
|
|
1595
|
-
memberJoins: Object.fromEntries(members.map((member) => [member, 1])),
|
|
1596
|
-
memberLeaves: {},
|
|
1597
|
-
invitedJoins: existing?.invitedJoins ?? {},
|
|
1598
|
-
invitedLeaves: existing?.invitedLeaves ?? {},
|
|
1599
|
-
federated: existing?.federated ?? false,
|
|
1600
|
-
};
|
|
1601
|
-
this.rooms.set(roomPath, room);
|
|
1602
|
-
return room;
|
|
350
|
+
return this.roomLifecycle.refreshRoomMembers(roomPath);
|
|
1603
351
|
}
|
|
1604
|
-
/**
|
|
1605
|
-
* The requester's own half of section 6's two-round DM consent flow: sends an ungated room.join scoped to dmRoomPath(this, counterpart) directly to the counterpart, records having initiated it so the counterpart's own reciprocal room.join back auto-approves rather than surfacing as a fresh, unsolicited request, and persists whatever grant comes back. Deliberately outside the CommsStore interface, like connection approval, since it is a wire-mesh-specific concern FileStore has no equivalent for. Safe to call again for the same counterpart later (e.g. after an earlier request expired or was rejected) -- it always sends a fresh request rather than checking for an existing token first.
|
|
1606
|
-
*/
|
|
352
|
+
/** The requester's own half of section 6's two-round DM consent flow. Deliberately outside the CommsStore interface, like connection approval, since it is a wire-mesh-specific concern FileStore has no equivalent for. Concrete-only -- reached directly by tests. */
|
|
1607
353
|
async requestDmAccess(counterpart) {
|
|
1608
|
-
|
|
1609
|
-
this.dmRequestsInitiatedByMe.add(dmPath);
|
|
1610
|
-
const outcome = await this.requireTransport().sendRoomRequest(counterpart, { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.join" } }, { kind: "room", path: dmPath });
|
|
1611
|
-
if (outcome.result !== "ok") {
|
|
1612
|
-
throw new CommsError(`DM access request to ${counterpart} was refused (${outcome.code})`, "JOIN_REFUSED");
|
|
1613
|
-
}
|
|
1614
|
-
const parsedOutcome = roomJoinOkSchema.safeParse(outcome);
|
|
1615
|
-
if (!parsedOutcome.success) {
|
|
1616
|
-
throw new CommsError(`DM access response from ${counterpart} was malformed`, "MALFORMED_RESPONSE");
|
|
1617
|
-
}
|
|
1618
|
-
const { slot } = this.requireIdentity();
|
|
1619
|
-
saveRoomToken(slot, dmPath, parsedOutcome.data["granted-token"]);
|
|
354
|
+
return this.roomLifecycle.requestDmAccess(counterpart);
|
|
1620
355
|
}
|
|
1621
|
-
/**
|
|
1622
|
-
* Joins a room. For this store's own identity, "already known locally" is not the right gate for skipping real admission: the legacy full-state-sync replicates a room's metadata to every mesh-connected peer the moment it's created, well before that peer has ever been admitted, so a room already present in this.rooms says nothing about whether this store actually holds a valid room:member token for it. The real gate is that token's presence -- absent, this always goes through joinRemoteRoom's real wire-level admission regardless of what this.rooms already knows, so a peer that merely heard about a room never mistakes hearing about it for having joined it. Joining on behalf of a DIFFERENT agentId (this store's own convergence/admin bookkeeping, exercised directly by state-sync-convergence.test.ts) is untouched -- that's a pure local CRDT mutation with no admission concept at all.
|
|
1623
|
-
*/
|
|
1624
356
|
async joinRoom(roomId, agentId) {
|
|
1625
|
-
|
|
1626
|
-
const { slot } = this.requireIdentity();
|
|
1627
|
-
if (loadRoomTokens(slot)[roomId] === undefined) {
|
|
1628
|
-
return this.joinRemoteRoom(roomId, agentId);
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
const room = this.rooms.get(roomId);
|
|
1632
|
-
if (!room)
|
|
1633
|
-
return this.joinRemoteRoom(roomId, agentId);
|
|
1634
|
-
const alreadyMember = room.members.includes(agentId);
|
|
1635
|
-
if (!alreadyMember && room.type !== "public") {
|
|
1636
|
-
if (!room.invited.includes(agentId) && room.owner !== agentId) {
|
|
1637
|
-
throw new CommsError(`Not invited to room ${roomId}`, "NOT_INVITED");
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
this.bump(room);
|
|
1641
|
-
this.recordMemberOp(room, "member", "join", agentId);
|
|
1642
|
-
if (alreadyMember || room.type !== "public") {
|
|
1643
|
-
// Consuming an invitation (or re-joining) retires the invited entry.
|
|
1644
|
-
this.recordMemberOp(room, "invited", "leave", agentId);
|
|
1645
|
-
}
|
|
1646
|
-
this.refreshMembership(room);
|
|
1647
|
-
this.rooms.set(roomId, room);
|
|
1648
|
-
const agent = this.agents.get(agentId);
|
|
1649
|
-
if (agent && !agent.subscribedRooms.includes(roomId)) {
|
|
1650
|
-
agent.subscribedRooms.push(roomId);
|
|
1651
|
-
this.bump(agent);
|
|
1652
|
-
this.agents.set(agentId, agent);
|
|
1653
|
-
await this.broadcastPatch({ type: "agent_upsert", agent });
|
|
1654
|
-
}
|
|
1655
|
-
await this.broadcastPatch({ type: "room_upsert", room });
|
|
1656
|
-
// Send current member list to the joining agent
|
|
1657
|
-
const members = [];
|
|
1658
|
-
for (const memberId of room.members) {
|
|
1659
|
-
const memberAgent = this.agents.get(memberId);
|
|
1660
|
-
if (memberAgent) {
|
|
1661
|
-
members.push({
|
|
1662
|
-
id: memberAgent.id,
|
|
1663
|
-
name: memberAgent.name,
|
|
1664
|
-
status: memberAgent.status,
|
|
1665
|
-
});
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
await this.deliverLocallyAndBroadcast(agentId, {
|
|
1669
|
-
type: "room_members",
|
|
1670
|
-
room: roomId,
|
|
1671
|
-
members,
|
|
1672
|
-
});
|
|
1673
|
-
// Notify existing members of the join
|
|
1674
|
-
await this.deliverToRoom(roomId, {
|
|
1675
|
-
type: "member_joined",
|
|
1676
|
-
room: roomId,
|
|
1677
|
-
agent: agentId,
|
|
1678
|
-
}, agentId);
|
|
1679
|
-
// Notify federated links if the room is federated
|
|
1680
|
-
if (room.federated) {
|
|
1681
|
-
const agentName = agent?.name ?? agentId;
|
|
1682
|
-
await this.federation.broadcastRoomJoin(roomId, agentId, agentName);
|
|
1683
|
-
}
|
|
1684
|
-
return room;
|
|
357
|
+
return this.roomLifecycle.joinRoom(roomId, agentId);
|
|
1685
358
|
}
|
|
1686
|
-
/**
|
|
1687
|
-
* Leaves a room. For this store's own identity leaving a room it does not itself own, the local Room object is only ever this store's own static snapshot from when it joined or was invited (P3.6/P3.8) -- mutating it directly, as the legacy branch below does, would tell nobody but this store itself. The real effect needs a wire round trip to the room's own owner instead, so this always defers to leaveRemoteRoom in that case. Leaving on behalf of a DIFFERENT agentId, or the owner leaving their own room (this store's own authoritative copy), is untouched -- that is the legacy CRDT mutation state-sync-convergence.test.ts exercises directly.
|
|
1688
|
-
*/
|
|
1689
359
|
async leaveRoom(roomId, agentId) {
|
|
1690
|
-
|
|
1691
|
-
if (!room)
|
|
1692
|
-
throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
|
|
1693
|
-
if (agentId === this.peerId && room.owner !== this.peerId) {
|
|
1694
|
-
return this.leaveRemoteRoom(roomId, room.owner);
|
|
1695
|
-
}
|
|
1696
|
-
this.bump(room);
|
|
1697
|
-
this.recordMemberOp(room, "member", "leave", agentId);
|
|
1698
|
-
this.refreshMembership(room);
|
|
1699
|
-
this.rooms.set(roomId, room);
|
|
1700
|
-
const agent = this.agents.get(agentId);
|
|
1701
|
-
if (agent) {
|
|
1702
|
-
agent.subscribedRooms = agent.subscribedRooms.filter((id) => id !== roomId);
|
|
1703
|
-
this.agents.set(agentId, agent);
|
|
1704
|
-
await this.broadcastPatch({ type: "agent_upsert", agent });
|
|
1705
|
-
}
|
|
1706
|
-
await this.broadcastPatch({ type: "room_upsert", room });
|
|
1707
|
-
await this.deliverToRoom(roomId, {
|
|
1708
|
-
type: "member_left",
|
|
1709
|
-
room: roomId,
|
|
1710
|
-
agent: agentId,
|
|
1711
|
-
});
|
|
1712
|
-
// Notify federated links if the room is federated
|
|
1713
|
-
if (room.federated) {
|
|
1714
|
-
await this.federation.broadcastRoomLeave(roomId, agentId);
|
|
1715
|
-
}
|
|
1716
|
-
if (room.members.length === 0 && room.owner === agentId) {
|
|
1717
|
-
await this.destroyRoom(roomId, agentId);
|
|
1718
|
-
}
|
|
360
|
+
return this.roomLifecycle.leaveRoom(roomId, agentId);
|
|
1719
361
|
}
|
|
1720
|
-
/**
|
|
1721
|
-
* The remote-leave path: roomPath names a room this store is a member of but does not own, so leaving for real means telling the owner over a real, wire-authenticated room.leave rather than mutating a local Room record nobody else reads. Also declineInvite's own mechanism (P3.8): the moment a target receives an invite (handleRoomInvite), it already holds a real, persisted room:member token exactly as if it had joined -- declining is simply leaving before ever really participating, and the owner's own receiving side (handleRoomLeave) tells the two cases apart by checking its own membership/invited lists, not by a separate verb. reason rides room.leave's own open extension tail so the owner can still surface a real decline reason without a second wire shape.
|
|
1722
|
-
*/
|
|
1723
|
-
async leaveRemoteRoom(roomPath, ownerId, reason) {
|
|
1724
|
-
const { slot } = this.requireIdentity();
|
|
1725
|
-
const token = loadRoomTokens(slot)[roomPath];
|
|
1726
|
-
if (token === undefined) {
|
|
1727
|
-
throw new CommsError(`No room:member token for ${roomPath}`, "NOT_MEMBER");
|
|
1728
|
-
}
|
|
1729
|
-
const params = {
|
|
1730
|
-
verb: "room.leave",
|
|
1731
|
-
...(reason !== undefined ? { reason } : {}),
|
|
1732
|
-
};
|
|
1733
|
-
const outcome = await this.requireTransport().sendRoomRequest(ownerId, { verb: ROOM_MEMBER_CAPABILITY, params }, { kind: "room", path: roomPath }, token);
|
|
1734
|
-
if (outcome.result !== "ok") {
|
|
1735
|
-
throw new CommsError(`Leaving ${roomPath} failed (${outcome.code})`, "LEAVE_FAILED");
|
|
1736
|
-
}
|
|
1737
|
-
deleteRoomToken(slot, roomPath);
|
|
1738
|
-
this.rooms.delete(roomPath);
|
|
1739
|
-
this.messages.delete(roomPath);
|
|
1740
|
-
}
|
|
1741
|
-
/**
|
|
1742
|
-
* Invites targetId to roomId, over a real, wire-authenticated room.invite (P3.8): mints a fresh room:member grant for the target, records its own token-id the same way admitRoomJoin does (kickFromRoom can revoke an invited member's own grant exactly as it can a joined one), and pushes the grant to the target directly in the invite request itself -- room.invite is deliberately ungated (the room's own owner needs no capability to invite, per core/room's design), so the target's own verification of the embedded token is what proves this invite is genuine, not anything about the connection it arrived on. Retains the local CRDT invited-list bookkeeping (still this store's own record of who it has invited) but no longer broadcasts it: the target learns of the invite from the real request, not a mesh-wide patch.
|
|
1743
|
-
*/
|
|
1744
362
|
async inviteToRoom(roomId, targetId, inviterId) {
|
|
1745
|
-
|
|
1746
|
-
if (!room)
|
|
1747
|
-
throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
|
|
1748
|
-
if (room.owner !== inviterId)
|
|
1749
|
-
throw new CommsError("Only the room owner can invite", "NOT_OWNER");
|
|
1750
|
-
this.bump(room);
|
|
1751
|
-
if (!room.invited.includes(targetId) && !room.members.includes(targetId)) {
|
|
1752
|
-
this.recordMemberOp(room, "invited", "join", targetId);
|
|
1753
|
-
}
|
|
1754
|
-
this.refreshMembership(room);
|
|
1755
|
-
this.rooms.set(roomId, room);
|
|
1756
|
-
const { identity, clock, slot } = this.requireIdentity();
|
|
1757
|
-
const tokenId = randomId();
|
|
1758
|
-
const verdict = await mintCapabilityToken({
|
|
1759
|
-
identity,
|
|
1760
|
-
clock,
|
|
1761
|
-
tokenId,
|
|
1762
|
-
bearer: deviceIdFromHex(targetId),
|
|
1763
|
-
capability: ROOM_MEMBER_CAPABILITY,
|
|
1764
|
-
scope: { kind: "room", path: roomId },
|
|
1765
|
-
expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
|
|
1766
|
-
delegationsRemaining: 0,
|
|
1767
|
-
});
|
|
1768
|
-
if (!verdict.ok) {
|
|
1769
|
-
throw new CommsError(`Failed to mint an invite grant for ${targetId}`, "MINT_FAILED");
|
|
1770
|
-
}
|
|
1771
|
-
saveIssuedRoomGrant(slot, roomId, targetId, tokenId);
|
|
1772
|
-
const inviter = this.agents.get(inviterId);
|
|
1773
|
-
const params = {
|
|
1774
|
-
verb: "room.invite",
|
|
1775
|
-
invitee: deviceIdFromHex(targetId),
|
|
1776
|
-
token: verdict.token,
|
|
1777
|
-
...MeshStore.roomStateExtension(room),
|
|
1778
|
-
...(inviter !== undefined
|
|
1779
|
-
? MeshStore.inviterAgentExtension(inviter)
|
|
1780
|
-
: {}),
|
|
1781
|
-
};
|
|
1782
|
-
const outcome = await this.requireTransport().sendRoomRequest(targetId, { verb: ROOM_MEMBER_CAPABILITY, params }, { kind: "room", path: roomId });
|
|
1783
|
-
if (outcome.result !== "ok") {
|
|
1784
|
-
throw new CommsError(`Invite to ${targetId} for ${roomId} failed (${outcome.code})`, "INVITE_FAILED");
|
|
1785
|
-
}
|
|
363
|
+
return this.roomLifecycle.inviteToRoom(roomId, targetId, inviterId);
|
|
1786
364
|
}
|
|
1787
|
-
/**
|
|
1788
|
-
* Declines a pending invite, over the same real room.leave request leaveRemoteRoom already sends for an actual leave (P3.8): the moment this store received the invite (handleRoomInvite), it already holds a real, persisted room:member token, so declining before ever really participating is simply leaving early -- the owner's own receiving side (handleRoomLeave) tells the two cases apart from its own membership/invited lists, not from a separate verb. Uses parseRoomPath rather than any locally cached Room record to find the owner to leave, since handleRoomInvite never constructs one -- there is nothing here to read a room.owner field off in the first place.
|
|
1789
|
-
*/
|
|
1790
365
|
async declineInvite(roomId, agentId, reason) {
|
|
1791
|
-
|
|
1792
|
-
if (agentId !== this.peerId) {
|
|
1793
|
-
throw new CommsError(`Cannot decline an invite on behalf of ${agentId}`, "NOT_SELF");
|
|
1794
|
-
}
|
|
1795
|
-
const parsed = parseRoomPath(roomId);
|
|
1796
|
-
if (parsed.kind !== "owner-named") {
|
|
1797
|
-
throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
|
|
1798
|
-
}
|
|
1799
|
-
return this.leaveRemoteRoom(roomId, parsed.owner, reason);
|
|
1800
|
-
}
|
|
1801
|
-
/**
|
|
1802
|
-
* Kicks targetId from roomId. Beyond the legacy CRDT membership removal (retired in P3.8 along with every other non-message broadcastPatch caller), this revokes the member's own room:member grant for real: mints a revocation-entry for the token-id this identity recorded when it admitted them (admitRoomJoin's own saveIssuedRoomGrant), records it in this store's own RevocationView immediately (so this identity's own future verifications see the kick without waiting on its own gossip), and announces it to every connected peer so each one's independent verification of the target's token -- not just this room's owner -- also starts failing as "revoked" from here on. Silently skips the revocation step (kick still happens; only the token-side enforcement doesn't) when no issued-grant record exists for this member, e.g. a grant predating this bookkeeping.
|
|
1803
|
-
*/
|
|
1804
|
-
/**
|
|
1805
|
-
* Revokes memberId's own room:member grant for roomId for real, if this identity ever recorded issuing one: mints a revocation-entry for its token-id, records it in this store's own RevocationView immediately, announces it to every connected peer, and forgets the issued-grant record (a later re-admission mints and records a genuinely fresh one rather than leaving a stale entry alongside it). Silently does nothing when no issued-grant record exists (a grant predating this bookkeeping, or a member who was never actually admitted a token at all) -- shared by kickFromRoom (owner-initiated) and handleRoomLeave (member-initiated, including a decline).
|
|
1806
|
-
*/
|
|
1807
|
-
async revokeMemberGrant(roomId, memberId) {
|
|
1808
|
-
const { identity, clock, slot, revocation } = this.requireIdentity();
|
|
1809
|
-
const tokenId = loadIssuedRoomGrant(slot, roomId, memberId);
|
|
1810
|
-
if (tokenId === undefined)
|
|
1811
|
-
return;
|
|
1812
|
-
const entry = await mintRevocationEntry({
|
|
1813
|
-
identity,
|
|
1814
|
-
tokenId,
|
|
1815
|
-
revokedAt: clock.now(),
|
|
1816
|
-
});
|
|
1817
|
-
await revocation.record(entry, { identity });
|
|
1818
|
-
await this.requireTransport().broadcastRevocation([entry]);
|
|
1819
|
-
deleteIssuedRoomGrant(slot, roomId, memberId);
|
|
366
|
+
return this.roomLifecycle.declineInvite(roomId, agentId, reason);
|
|
1820
367
|
}
|
|
1821
368
|
async kickFromRoom(roomId, targetId, kickerId) {
|
|
1822
|
-
|
|
1823
|
-
if (!room)
|
|
1824
|
-
throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
|
|
1825
|
-
if (room.owner !== kickerId)
|
|
1826
|
-
throw new CommsError("Only the room owner can kick", "NOT_OWNER");
|
|
1827
|
-
await this.revokeMemberGrant(roomId, targetId);
|
|
1828
|
-
this.bump(room);
|
|
1829
|
-
this.recordMemberOp(room, "member", "leave", targetId);
|
|
1830
|
-
this.recordMemberOp(room, "invited", "leave", targetId);
|
|
1831
|
-
this.refreshMembership(room);
|
|
1832
|
-
this.rooms.set(roomId, room);
|
|
1833
|
-
await this.broadcastPatch({ type: "room_upsert", room });
|
|
369
|
+
return this.roomLifecycle.kickFromRoom(roomId, targetId, kickerId);
|
|
1834
370
|
}
|
|
1835
|
-
/**
|
|
1836
|
-
* Destroys roomId, revoking every member's own room:member grant for real first (P3.8) -- the same revocation machinery kickFromRoom already uses, since destroying a room out from under its members is exactly as much a membership revocation as kicking them individually would be, just for all of them at once. Room-existence notification (agent_upsert/room_delete) stays on the legacy broadcastPatch for now: replacing it needs the same broader informational-events redesign the rest of P3.8 already tracks as separate, larger work, not something this specific fix should improvise alone.
|
|
1837
|
-
*/
|
|
1838
371
|
async destroyRoom(roomId, agentId) {
|
|
1839
|
-
|
|
1840
|
-
if (!room)
|
|
1841
|
-
throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
|
|
1842
|
-
if (room.owner !== agentId)
|
|
1843
|
-
throw new CommsError("Only the room owner can destroy", "NOT_OWNER");
|
|
1844
|
-
for (const memberId of room.members) {
|
|
1845
|
-
await this.revokeMemberGrant(roomId, memberId);
|
|
1846
|
-
const member = this.agents.get(memberId);
|
|
1847
|
-
if (member) {
|
|
1848
|
-
member.subscribedRooms = member.subscribedRooms.filter((id) => id !== roomId);
|
|
1849
|
-
this.agents.set(memberId, member);
|
|
1850
|
-
await this.broadcastPatch({ type: "agent_upsert", agent: member });
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
this.rooms.delete(roomId);
|
|
1854
|
-
this.messages.delete(roomId);
|
|
1855
|
-
await this.broadcastPatch({ type: "room_delete", roomId });
|
|
372
|
+
return this.roomLifecycle.destroyRoom(roomId, agentId);
|
|
1856
373
|
}
|
|
1857
374
|
// -----------------------------------------------------------------------
|
|
1858
375
|
// CommsStore — Messages
|
|
1859
376
|
// -----------------------------------------------------------------------
|
|
1860
|
-
/**
|
|
1861
|
-
* Sends a room message via a real, wire-authenticated room.send fan-out (P3.5): one directed request per member, each carrying this sender's own persisted room:member token, rather than the legacy broadcastPatch's full-state replication. A member unreachable right now is queued for retry (see sendRoomRequestToMember/flushPendingRoomRequests) instead of blocking or failing the whole send -- delivery to any one recipient is independent of every other.
|
|
1862
|
-
*/
|
|
1863
377
|
async sendRoomMessage(roomId, from, content, replyTo, streamingBehavior) {
|
|
1864
|
-
|
|
1865
|
-
if (!room)
|
|
1866
|
-
throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
|
|
1867
|
-
if (!room.members.includes(from))
|
|
1868
|
-
throw new CommsError(`Not a member of ${roomId}`, "NOT_MEMBER");
|
|
1869
|
-
const { slot, clock } = this.requireIdentity();
|
|
1870
|
-
const token = loadRoomTokens(slot)[roomId];
|
|
1871
|
-
if (token === undefined) {
|
|
1872
|
-
throw new CommsError(`No room:member token for ${roomId}`, "NOT_MEMBER");
|
|
1873
|
-
}
|
|
1874
|
-
const messageId = randomId();
|
|
1875
|
-
const id = bytesToHex(messageId);
|
|
1876
|
-
const message = {
|
|
1877
|
-
id,
|
|
1878
|
-
from,
|
|
1879
|
-
room: roomId,
|
|
1880
|
-
content,
|
|
1881
|
-
timestamp: new Date().toISOString(),
|
|
1882
|
-
readBy: [from],
|
|
1883
|
-
...(replyTo !== undefined && { replyTo }),
|
|
1884
|
-
...(streamingBehavior !== undefined && { streamingBehavior }),
|
|
1885
|
-
};
|
|
1886
|
-
const arr = this.messages.get(roomId) ?? [];
|
|
1887
|
-
arr.push(message);
|
|
1888
|
-
this.messages.set(roomId, arr);
|
|
1889
|
-
// Forward to federated links if the room is federated
|
|
1890
|
-
if (room.federated) {
|
|
1891
|
-
await this.federation.forwardRoomMessage(roomId, message);
|
|
1892
|
-
}
|
|
1893
|
-
const params = {
|
|
1894
|
-
verb: "room.send",
|
|
1895
|
-
"message-id": messageId,
|
|
1896
|
-
"sent-at": clock.now(),
|
|
1897
|
-
text: content,
|
|
1898
|
-
...(replyTo !== undefined && {
|
|
1899
|
-
refs: [{ id: bytesFromHex(replyTo), relation: "reply" }],
|
|
1900
|
-
}),
|
|
1901
|
-
...(streamingBehavior !== undefined && {
|
|
1902
|
-
"streaming-behavior": streamingBehavior,
|
|
1903
|
-
}),
|
|
1904
|
-
};
|
|
1905
|
-
for (const memberId of room.members) {
|
|
1906
|
-
if (memberId !== from) {
|
|
1907
|
-
await this.sendRoomRequestToMember(memberId, roomId, token, params);
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
return message;
|
|
378
|
+
return this.roomMessaging.sendRoomMessage(roomId, from, content, replyTo, streamingBehavior);
|
|
1911
379
|
}
|
|
1912
380
|
async readRoomMessages(roomId, since) {
|
|
1913
|
-
|
|
1914
|
-
const arr = this.messages.get(roomId) ?? [];
|
|
1915
|
-
if (!since)
|
|
1916
|
-
return [...arr];
|
|
1917
|
-
return arr.filter((m) => m.timestamp > since);
|
|
381
|
+
return this.roomMessaging.readRoomMessages(roomId, since);
|
|
1918
382
|
}
|
|
1919
383
|
// -----------------------------------------------------------------------
|
|
1920
384
|
// CommsStore — DMs
|
|
1921
385
|
// -----------------------------------------------------------------------
|
|
1922
|
-
/**
|
|
1923
|
-
* Sends a DM via the same wire-authenticated room.send fan-out sendRoomMessage uses (P3.5): a DM is just a dm-shaped room path with exactly one other member, so it rides the identical mechanism rather than a separate one. Self-DM is the one exception -- a purely local scratchpad note that never leaves the process, so it needs no token and no wire round trip at all.
|
|
1924
|
-
*/
|
|
1925
386
|
async sendDm(from, to, content, streamingBehavior) {
|
|
1926
|
-
|
|
1927
|
-
const recipient = this.agents.get(to);
|
|
1928
|
-
if (!recipient)
|
|
1929
|
-
throw new CommsError(`Agent ${to} not found`, "AGENT_NOT_FOUND");
|
|
1930
|
-
if (recipient.visibility === "ghost")
|
|
1931
|
-
throw new CommsError(`Cannot DM agent ${to}`, "AGENT_NOT_FOUND");
|
|
1932
|
-
}
|
|
1933
|
-
const messageId = randomId();
|
|
1934
|
-
const id = bytesToHex(messageId);
|
|
1935
|
-
const message = {
|
|
1936
|
-
id,
|
|
1937
|
-
from,
|
|
1938
|
-
to,
|
|
1939
|
-
content,
|
|
1940
|
-
timestamp: new Date().toISOString(),
|
|
1941
|
-
readBy: [from],
|
|
1942
|
-
...(streamingBehavior !== undefined && { streamingBehavior }),
|
|
1943
|
-
};
|
|
1944
|
-
// Self-DM is a purely local scratchpad note -- it never leaves the process, so it needs no room-path and dmRoomPath's own a===b refusal (a path naming the same device twice is not a valid DM path at all) correctly does not apply here.
|
|
1945
|
-
const key = to === from ? `self:${from}` : dmRoomPath(from, to);
|
|
1946
|
-
const arr = this.dms.get(key) ?? [];
|
|
1947
|
-
arr.push(message);
|
|
1948
|
-
this.dms.set(key, arr);
|
|
1949
|
-
if (to !== from) {
|
|
1950
|
-
const { slot, clock } = this.requireIdentity();
|
|
1951
|
-
const token = loadRoomTokens(slot)[key];
|
|
1952
|
-
if (token === undefined) {
|
|
1953
|
-
throw new CommsError(`No room:member token for ${key}`, "NOT_MEMBER");
|
|
1954
|
-
}
|
|
1955
|
-
const params = {
|
|
1956
|
-
verb: "room.send",
|
|
1957
|
-
"message-id": messageId,
|
|
1958
|
-
"sent-at": clock.now(),
|
|
1959
|
-
text: content,
|
|
1960
|
-
...(streamingBehavior !== undefined && {
|
|
1961
|
-
"streaming-behavior": streamingBehavior,
|
|
1962
|
-
}),
|
|
1963
|
-
};
|
|
1964
|
-
await this.sendRoomRequestToMember(to, key, token, params);
|
|
1965
|
-
}
|
|
1966
|
-
return message;
|
|
387
|
+
return this.roomMessaging.sendDm(from, to, content, streamingBehavior);
|
|
1967
388
|
}
|
|
1968
389
|
// -----------------------------------------------------------------------
|
|
1969
390
|
// CommsStore — Delivery
|
|
1970
391
|
// -----------------------------------------------------------------------
|
|
1971
392
|
async deliver(agentId, event) {
|
|
1972
|
-
|
|
393
|
+
return this.deliveryEngine.deliver(agentId, event);
|
|
1973
394
|
}
|
|
1974
395
|
async drainDelivery(agentId) {
|
|
1975
|
-
|
|
1976
|
-
const events = this.deliveryQueues.get(agentId) ?? [];
|
|
1977
|
-
this.deliveryQueues.set(agentId, []);
|
|
1978
|
-
// Auto-mark messages as read — drain bridges consume on tool call
|
|
1979
|
-
for (const event of events) {
|
|
1980
|
-
if (event.type === "room_message") {
|
|
1981
|
-
await this.markRead(event.message.id, agentId, event.message.room);
|
|
1982
|
-
}
|
|
1983
|
-
else if (event.type === "dm") {
|
|
1984
|
-
await this.markRead(event.message.id, agentId);
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
return events;
|
|
396
|
+
return this.deliveryEngine.drainDelivery(agentId);
|
|
1988
397
|
}
|
|
1989
398
|
// -----------------------------------------------------------------------
|
|
1990
|
-
//
|
|
399
|
+
// Concrete-only surface reached directly by tests
|
|
1991
400
|
// -----------------------------------------------------------------------
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
this.staleCheckTimer = setInterval(() => {
|
|
1996
|
-
void this.probeStaleAgents();
|
|
1997
|
-
}, 5000);
|
|
401
|
+
/** Merge a peer's state snapshot into the local state (#27/#28). Concrete-only -- reached directly by tests. */
|
|
402
|
+
applyStateSync(state) {
|
|
403
|
+
this.deliveryEngine.applyStateSync(state);
|
|
1998
404
|
}
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
this.staleCheckTimer = undefined;
|
|
2003
|
-
}
|
|
405
|
+
/** Sends one directed room.send to a single member's own session (P3.5). Concrete-only -- reached directly by tests. */
|
|
406
|
+
async sendRoomMessageDirected(roomPath, memberId, text) {
|
|
407
|
+
return this.roomProtocol.sendRoomMessageDirected(roomPath, memberId, text);
|
|
2004
408
|
}
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
if (agent.status !== "active")
|
|
2009
|
-
continue;
|
|
2010
|
-
if (!this.isProcessAlive(agent.pid)) {
|
|
2011
|
-
deadIds.push(id);
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
for (const id of deadIds) {
|
|
2015
|
-
const agent = this.agents.get(id);
|
|
2016
|
-
if (agent) {
|
|
2017
|
-
agent.status = "offline";
|
|
2018
|
-
this.agents.set(id, agent);
|
|
2019
|
-
await this.notifyRoomsOfStatus(id, "offline");
|
|
2020
|
-
}
|
|
2021
|
-
await this.broadcastPatch({ type: "agent_offline", agentId: id });
|
|
2022
|
-
this.peerInfo.delete(id);
|
|
2023
|
-
}
|
|
2024
|
-
// Also purge long-offline agents to prevent indefinite accumulation
|
|
2025
|
-
const offlineThreshold = Date.now() - 30 * 60 * 1000; // 30 minutes
|
|
2026
|
-
const purgeIds = [];
|
|
2027
|
-
for (const [id, agent] of this.agents) {
|
|
2028
|
-
if (agent.status !== "offline")
|
|
2029
|
-
continue;
|
|
2030
|
-
const startedAt = new Date(agent.startedAt).getTime();
|
|
2031
|
-
if (startedAt < offlineThreshold) {
|
|
2032
|
-
purgeIds.push(id);
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
for (const id of purgeIds) {
|
|
2036
|
-
this.agents.delete(id);
|
|
2037
|
-
this.peerInfo.delete(id);
|
|
2038
|
-
this.identityCache.delete(id);
|
|
2039
|
-
this.deliveryQueues.delete(id);
|
|
2040
|
-
}
|
|
409
|
+
/** Starts only the data server without connecting to a coordinator. Used for testing scenarios where the peer connects via connectToRemote. */
|
|
410
|
+
async startDataServerOnly() {
|
|
411
|
+
return this.connectionApproval.startDataServerOnly();
|
|
2041
412
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
413
|
+
// -----------------------------------------------------------------------
|
|
414
|
+
// Connection approval (mesh-only)
|
|
415
|
+
// -----------------------------------------------------------------------
|
|
416
|
+
/** Accept a pending inbound connection. */
|
|
417
|
+
async acceptConnection(connectionId) {
|
|
418
|
+
return this.connectionApproval.acceptConnection(connectionId);
|
|
419
|
+
}
|
|
420
|
+
/** Reject a pending inbound connection. */
|
|
421
|
+
async rejectConnection(connectionId, reason) {
|
|
422
|
+
return this.connectionApproval.rejectConnection(connectionId, reason);
|
|
423
|
+
}
|
|
424
|
+
/** List all pending inbound connections awaiting approval. */
|
|
425
|
+
listPendingConnections() {
|
|
426
|
+
return this.connectionApproval.listPendingConnections();
|
|
427
|
+
}
|
|
428
|
+
/** Initiate an outbound connection to a remote coordinator requiring approval. */
|
|
429
|
+
connectToRemote(host, port) {
|
|
430
|
+
return this.connectionApproval.connectToRemote(host, port);
|
|
431
|
+
}
|
|
432
|
+
// -----------------------------------------------------------------------
|
|
433
|
+
// Room-join approval (mesh-only)
|
|
434
|
+
// -----------------------------------------------------------------------
|
|
435
|
+
/** Every room.join request currently held open awaiting this store's own accept/reject decision. */
|
|
436
|
+
listPendingRoomJoins() {
|
|
437
|
+
return this.roomProtocol.listPendingRoomJoins();
|
|
438
|
+
}
|
|
439
|
+
/** Approves a pending room.join request. */
|
|
440
|
+
acceptRoomJoin(roomPath, requesterId) {
|
|
441
|
+
this.roomProtocol.acceptRoomJoin(roomPath, requesterId);
|
|
442
|
+
}
|
|
443
|
+
/** Denies a pending room.join request, optionally with a reason. */
|
|
444
|
+
rejectRoomJoin(roomPath, requesterId, reason) {
|
|
445
|
+
this.roomProtocol.rejectRoomJoin(roomPath, requesterId, reason);
|
|
2050
446
|
}
|
|
2051
447
|
// -----------------------------------------------------------------------
|
|
2052
448
|
// Mesh visibility
|
|
@@ -2127,113 +523,11 @@ export class MeshStore {
|
|
|
2127
523
|
return this.federation.stopListening();
|
|
2128
524
|
}
|
|
2129
525
|
// -----------------------------------------------------------------------
|
|
2130
|
-
// Federation callbacks (inbound from remote meshes)
|
|
2131
|
-
// -----------------------------------------------------------------------
|
|
2132
|
-
async handleFedAgentVisible(agent) {
|
|
2133
|
-
// Store remote agent with a prefixed ID to avoid collisions with local agents
|
|
2134
|
-
const remoteId = `fed:${agent.id}@${agent.harness}`;
|
|
2135
|
-
const remoteAgent = {
|
|
2136
|
-
...agent,
|
|
2137
|
-
id: remoteId,
|
|
2138
|
-
tags: [...agent.tags, "federated"],
|
|
2139
|
-
};
|
|
2140
|
-
this.agents.set(remoteId, remoteAgent);
|
|
2141
|
-
await this.broadcastPatch({ type: "agent_upsert", agent: remoteAgent });
|
|
2142
|
-
}
|
|
2143
|
-
async handleFedAgentGone(agentId) {
|
|
2144
|
-
// The agentId comes from the remote mesh — we need to find the prefixed version
|
|
2145
|
-
const prefix = `fed:${agentId}@`;
|
|
2146
|
-
for (const [localId, agent] of this.agents) {
|
|
2147
|
-
if (localId.startsWith(prefix)) {
|
|
2148
|
-
agent.status = "offline";
|
|
2149
|
-
this.agents.set(localId, agent);
|
|
2150
|
-
await this.broadcastPatch({ type: "agent_offline", agentId: localId });
|
|
2151
|
-
break;
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2155
|
-
async handleFedRoomMessage(roomId, message) {
|
|
2156
|
-
const room = this.rooms.get(roomId);
|
|
2157
|
-
if (!room?.federated)
|
|
2158
|
-
return;
|
|
2159
|
-
// Store the message locally
|
|
2160
|
-
const arr = this.messages.get(roomId) ?? [];
|
|
2161
|
-
arr.push(message);
|
|
2162
|
-
this.messages.set(roomId, arr);
|
|
2163
|
-
// Deliver to all local room members
|
|
2164
|
-
for (const memberId of room.members) {
|
|
2165
|
-
await this.deliverLocallyAndBroadcast(memberId, {
|
|
2166
|
-
type: "room_message",
|
|
2167
|
-
message,
|
|
2168
|
-
});
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
async handleFedRoomJoin(roomId, agentId, _agentName) {
|
|
2172
|
-
const room = this.rooms.get(roomId);
|
|
2173
|
-
if (!room?.federated)
|
|
2174
|
-
return;
|
|
2175
|
-
// Create a shadow agent ID for the remote agent
|
|
2176
|
-
const remoteId = `fed:${agentId}`;
|
|
2177
|
-
if (!room.members.includes(remoteId)) {
|
|
2178
|
-
this.bump(room);
|
|
2179
|
-
this.recordMemberOp(room, "member", "join", remoteId);
|
|
2180
|
-
this.refreshMembership(room);
|
|
2181
|
-
this.rooms.set(roomId, room);
|
|
2182
|
-
await this.broadcastPatch({ type: "room_upsert", room });
|
|
2183
|
-
}
|
|
2184
|
-
// Notify local members
|
|
2185
|
-
await this.deliverToRoom(roomId, {
|
|
2186
|
-
type: "member_joined",
|
|
2187
|
-
room: roomId,
|
|
2188
|
-
agent: remoteId,
|
|
2189
|
-
}, remoteId);
|
|
2190
|
-
}
|
|
2191
|
-
async handleFedRoomLeave(roomId, agentId) {
|
|
2192
|
-
const room = this.rooms.get(roomId);
|
|
2193
|
-
if (!room?.federated)
|
|
2194
|
-
return;
|
|
2195
|
-
const remoteId = `fed:${agentId}`;
|
|
2196
|
-
this.bump(room);
|
|
2197
|
-
this.recordMemberOp(room, "member", "leave", remoteId);
|
|
2198
|
-
this.refreshMembership(room);
|
|
2199
|
-
this.rooms.set(roomId, room);
|
|
2200
|
-
await this.broadcastPatch({ type: "room_upsert", room });
|
|
2201
|
-
await this.deliverToRoom(roomId, {
|
|
2202
|
-
type: "member_left",
|
|
2203
|
-
room: roomId,
|
|
2204
|
-
agent: remoteId,
|
|
2205
|
-
});
|
|
2206
|
-
}
|
|
2207
|
-
getVisibleAgentsForFed() {
|
|
2208
|
-
const result = [];
|
|
2209
|
-
for (const agent of this.agents.values()) {
|
|
2210
|
-
// Only broadcast agents that belong to this mesh (not federated)
|
|
2211
|
-
// and are visible
|
|
2212
|
-
if (agent.visibility === "visible" && !agent.id.startsWith("fed:")) {
|
|
2213
|
-
result.push(agent);
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
return result;
|
|
2217
|
-
}
|
|
2218
|
-
getFederatedRoomMembershipsForFed() {
|
|
2219
|
-
const result = new Map();
|
|
2220
|
-
for (const [roomId, room] of this.rooms) {
|
|
2221
|
-
if (room.federated) {
|
|
2222
|
-
// Only include local members (not federated ones)
|
|
2223
|
-
const localMembers = room.members.filter((m) => !m.startsWith("fed:"));
|
|
2224
|
-
result.set(roomId, localMembers);
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
2227
|
-
return result;
|
|
2228
|
-
}
|
|
2229
|
-
// -----------------------------------------------------------------------
|
|
2230
526
|
// Shutdown
|
|
2231
527
|
// -----------------------------------------------------------------------
|
|
2232
528
|
async shutdown() {
|
|
2233
529
|
this.isShutDown = true;
|
|
2234
|
-
// Clear any pending markRead timers so they don't fire after the
|
|
2235
|
-
// transport is shut down (which would attempt sends on closed sockets)
|
|
2236
|
-
// or keep the event loop alive after process.exit().
|
|
530
|
+
// Clear any pending markRead timers so they don't fire after the transport is shut down (which would attempt sends on closed sockets) or keep the event loop alive after process.exit().
|
|
2237
531
|
for (const timer of this.pendingMarkReadTimers) {
|
|
2238
532
|
clearTimeout(timer);
|
|
2239
533
|
}
|
|
@@ -2241,12 +535,12 @@ export class MeshStore {
|
|
|
2241
535
|
const agent = this.agents.get(this.peerId);
|
|
2242
536
|
if (agent) {
|
|
2243
537
|
agent.status = "offline";
|
|
2244
|
-
await this.broadcastPatch({
|
|
538
|
+
await this.deliveryEngine.broadcastPatch({
|
|
2245
539
|
type: "agent_offline",
|
|
2246
540
|
agentId: this.peerId,
|
|
2247
541
|
});
|
|
2248
542
|
}
|
|
2249
|
-
this.
|
|
543
|
+
this.staleAgentChecker.stop();
|
|
2250
544
|
await this.federation.shutdown();
|
|
2251
545
|
await this.requireTransport().shutdown();
|
|
2252
546
|
}
|