agent-comms 2.10.0 → 2.12.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/mesh-store.d.ts +33 -11
- package/dist/core/mesh-store.d.ts.map +1 -1
- package/dist/core/mesh-store.js +237 -69
- package/dist/core/mesh-store.js.map +1 -1
- package/dist/core/wire-protocol.d.ts +0 -5
- package/dist/core/wire-protocol.d.ts.map +1 -1
- package/dist/core/wire-protocol.js.map +1 -1
- package/dist/test/mesh-smoke.runner.js +12 -6
- package/dist/test/mesh-smoke.runner.js.map +1 -1
- package/dist/test/room-read-directed.integration.test.d.ts +7 -0
- package/dist/test/room-read-directed.integration.test.d.ts.map +1 -0
- package/dist/test/room-read-directed.integration.test.js +132 -0
- package/dist/test/room-read-directed.integration.test.js.map +1 -0
- package/dist/test/room-state-sync.integration.test.d.ts +5 -0
- package/dist/test/room-state-sync.integration.test.d.ts.map +1 -0
- package/dist/test/room-state-sync.integration.test.js +106 -0
- package/dist/test/room-state-sync.integration.test.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ExaDev/agent-comms)
|
|
4
4
|
[](https://www.npmjs.com/package/agent-comms)
|
|
5
|
-
[](https://github.com/ExaDev/agent-comms/releases/tag/v2.12.0)
|
|
6
6
|
[](https://github.com/ExaDev/agent-comms/actions)
|
|
7
7
|
|
|
8
8
|
Cross-harness communication mesh for LLM agents: rooms, DMs, presence, and visibility over TCP with zero filesystem dependencies.
|
|
@@ -25,8 +25,8 @@ import type { IdentitySlot } from "./identity-store.js";
|
|
|
25
25
|
import type { MeshStatePatch } from "./wire-protocol.js";
|
|
26
26
|
import type { MeshTransport, TransportEvents } from "./transport.js";
|
|
27
27
|
import type { CommsStore } from "./comms-store.js";
|
|
28
|
-
import { StreamingBehavior } from "./types.js";
|
|
29
|
-
import type { AgentIdentity, DeliveryEvent, DmMessage, MeshVisibility, NetworkInterface, Room, RoomMessage,
|
|
28
|
+
import { RoomType, StreamingBehavior } from "./types.js";
|
|
29
|
+
import type { AgentIdentity, DeliveryEvent, DmMessage, MeshVisibility, NetworkInterface, Room, RoomMessage, Visibility } from "./types.js";
|
|
30
30
|
import type { ListenerInfo } from "./transport.js";
|
|
31
31
|
/** The identity/clock/persistence collaborators MeshStore mints and persists room-membership grants against. Set via setIdentity(), mirroring the transport's own setTransport() contract. */
|
|
32
32
|
export interface MeshStoreIdentity {
|
|
@@ -45,8 +45,8 @@ export declare class MeshStore implements CommsStore {
|
|
|
45
45
|
private dms;
|
|
46
46
|
private deliveryQueues;
|
|
47
47
|
private identityCache;
|
|
48
|
-
/** Directed room.send
|
|
49
|
-
private
|
|
48
|
+
/** Directed room-domain requests (room.send, room.read) that failed because their target member wasn't reachable at send time, held for retry when that member's own connection is (re)established -- the wire-authenticated fan-out's substitute for the legacy full-state-sync's own automatic eventual consistency, since a direct request to a disconnected peer fails immediately with no protocol-level retry of its own. Keyed by member device-id hex, bounded oldest-first per member with the same cap ordinary delivery queues use. */
|
|
49
|
+
private pendingRoomRequests;
|
|
50
50
|
private transport;
|
|
51
51
|
private storeIdentity;
|
|
52
52
|
private peerInfo;
|
|
@@ -162,8 +162,12 @@ export declare class MeshStore implements CommsStore {
|
|
|
162
162
|
private notifyRoomsOfNameChange;
|
|
163
163
|
private emitDeliveryStatus;
|
|
164
164
|
private findMessageSender;
|
|
165
|
+
/** 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. */
|
|
166
|
+
private findMessageLocation;
|
|
167
|
+
/**
|
|
168
|
+
* 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.
|
|
169
|
+
*/
|
|
165
170
|
private markRead;
|
|
166
|
-
private applyReadReceipt;
|
|
167
171
|
readIdentity(harness: string, cwd: string): Promise<{
|
|
168
172
|
id: string;
|
|
169
173
|
} | undefined>;
|
|
@@ -196,24 +200,38 @@ export declare class MeshStore implements CommsStore {
|
|
|
196
200
|
* 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.
|
|
197
201
|
*/
|
|
198
202
|
private handleRoomSend;
|
|
203
|
+
/**
|
|
204
|
+
* 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.
|
|
205
|
+
*/
|
|
206
|
+
private handleRoomRead;
|
|
207
|
+
/**
|
|
208
|
+
* 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.
|
|
209
|
+
*/
|
|
210
|
+
private handleRoomMembers;
|
|
199
211
|
/**
|
|
200
212
|
* 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.
|
|
201
213
|
*/
|
|
202
214
|
sendRoomMessageDirected(roomPath: string, memberId: string, text: string): Promise<void>;
|
|
203
|
-
/** Records a room
|
|
204
|
-
private
|
|
215
|
+
/** 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. */
|
|
216
|
+
private queuePendingRoomRequest;
|
|
205
217
|
/**
|
|
206
|
-
* Sends one directed room.send 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
|
|
218
|
+
* 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.
|
|
207
219
|
*/
|
|
208
|
-
private
|
|
220
|
+
private sendRoomRequestToMember;
|
|
209
221
|
/** 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. */
|
|
210
|
-
private
|
|
222
|
+
private flushPendingRoomRequests;
|
|
211
223
|
/**
|
|
212
224
|
* 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.
|
|
213
225
|
*/
|
|
214
226
|
private handleRoomJoin;
|
|
215
227
|
/** 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. */
|
|
216
228
|
private admitRoomJoin;
|
|
229
|
+
/**
|
|
230
|
+
* 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.
|
|
231
|
+
*/
|
|
232
|
+
private static roomStateExtension;
|
|
233
|
+
/** 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). */
|
|
234
|
+
private static parseRoomStateExtension;
|
|
217
235
|
/** Every room.join request currently held open awaiting this store's own accept/reject decision. */
|
|
218
236
|
listPendingRoomJoins(): {
|
|
219
237
|
roomPath: string;
|
|
@@ -236,6 +254,10 @@ export declare class MeshStore implements CommsStore {
|
|
|
236
254
|
* 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.
|
|
237
255
|
*/
|
|
238
256
|
private joinRemoteRoom;
|
|
257
|
+
/**
|
|
258
|
+
* 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.
|
|
259
|
+
*/
|
|
260
|
+
refreshRoomMembers(roomPath: string): Promise<Room>;
|
|
239
261
|
/**
|
|
240
262
|
* 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.
|
|
241
263
|
*/
|
|
@@ -250,7 +272,7 @@ export declare class MeshStore implements CommsStore {
|
|
|
250
272
|
kickFromRoom(roomId: string, targetId: string, kickerId: string): Promise<void>;
|
|
251
273
|
destroyRoom(roomId: string, agentId: string): Promise<void>;
|
|
252
274
|
/**
|
|
253
|
-
* 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
|
|
275
|
+
* 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.
|
|
254
276
|
*/
|
|
255
277
|
sendRoomMessage(roomId: string, from: string, content: string, replyTo?: string, streamingBehavior?: StreamingBehavior): Promise<RoomMessage>;
|
|
256
278
|
readRoomMessages(roomId: string, since?: string): Promise<RoomMessage[]>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mesh-store.d.ts","sourceRoot":"","sources":["../../src/core/mesh-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAYH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGlD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAS/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAClE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"mesh-store.d.ts","sourceRoot":"","sources":["../../src/core/mesh-store.ts"],"names":[],"mappings":"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"}
|
package/dist/core/mesh-store.js
CHANGED
|
@@ -25,11 +25,11 @@ import { FederationManager } from "./federation.js";
|
|
|
25
25
|
import { getCertificateFingerprint } from "./identity.js";
|
|
26
26
|
import { bytesFromHex, bytesToHex, deviceIdFromHex, deviceIdToHex, } from "wire-mesh-core/domain/device-id";
|
|
27
27
|
import { mintCapabilityToken } from "wire-mesh-core/domain/tokens";
|
|
28
|
-
import { roomJoinOkSchema, roomSendSchema, } from "wire-mesh-core/generated/protocol";
|
|
28
|
+
import { roomJoinOkSchema, roomMembersOkSchema, roomReadSchema, roomSendSchema, } from "wire-mesh-core/generated/protocol";
|
|
29
29
|
import { ROOM_MEMBER_CAPABILITY, verifyRoomToken, } from "./room-token-verification.js";
|
|
30
30
|
import { loadRoomTokens, saveRoomToken } from "./identity-store.js";
|
|
31
31
|
import { randomId } from "./random-id.js";
|
|
32
|
-
import { StreamingBehavior } from "./types.js";
|
|
32
|
+
import { RoomType, StreamingBehavior } from "./types.js";
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
// Constants
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
@@ -78,8 +78,8 @@ export class MeshStore {
|
|
|
78
78
|
dms = new Map();
|
|
79
79
|
deliveryQueues = new Map();
|
|
80
80
|
identityCache = new Map();
|
|
81
|
-
/** Directed room.send
|
|
82
|
-
|
|
81
|
+
/** Directed room-domain requests (room.send, room.read) that failed because their target member wasn't reachable at send time, held for retry when that member's own connection is (re)established -- the wire-authenticated fan-out's substitute for the legacy full-state-sync's own automatic eventual consistency, since a direct request to a disconnected peer fails immediately with no protocol-level retry of its own. Keyed by member device-id hex, bounded oldest-first per member with the same cap ordinary delivery queues use. */
|
|
82
|
+
pendingRoomRequests = new Map();
|
|
83
83
|
transport;
|
|
84
84
|
storeIdentity;
|
|
85
85
|
peerInfo = new Map();
|
|
@@ -255,7 +255,7 @@ export class MeshStore {
|
|
|
255
255
|
state,
|
|
256
256
|
});
|
|
257
257
|
}
|
|
258
|
-
await this.
|
|
258
|
+
await this.flushPendingRoomRequests(handle.id);
|
|
259
259
|
}
|
|
260
260
|
/**
|
|
261
261
|
* Merge a peer's state snapshot into the local state. Agents and rooms
|
|
@@ -640,10 +640,6 @@ export class MeshStore {
|
|
|
640
640
|
}
|
|
641
641
|
break;
|
|
642
642
|
}
|
|
643
|
-
case "message_read": {
|
|
644
|
-
this.applyReadReceipt(patch.messageId, patch.readBy, patch.room);
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
647
643
|
}
|
|
648
644
|
if (this.onPatch) {
|
|
649
645
|
await this.onPatch(patch);
|
|
@@ -796,50 +792,44 @@ export class MeshStore {
|
|
|
796
792
|
}
|
|
797
793
|
return undefined;
|
|
798
794
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
if (room) {
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
const msg = msgs.find((m) => m.id === messageId);
|
|
805
|
-
if (msg && !msg.readBy.includes(readBy)) {
|
|
806
|
-
msg.readBy.push(readBy);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
795
|
+
/** 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. */
|
|
796
|
+
findMessageLocation(messageId, room) {
|
|
797
|
+
if (room !== undefined) {
|
|
798
|
+
const msg = this.messages.get(room)?.find((m) => m.id === messageId);
|
|
799
|
+
return msg === undefined ? undefined : { roomPath: room, from: msg.from };
|
|
809
800
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
msg.readBy.push(readBy);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
801
|
+
for (const [key, msgs] of this.dms) {
|
|
802
|
+
const msg = msgs.find((m) => m.id === messageId);
|
|
803
|
+
if (msg)
|
|
804
|
+
return { roomPath: key, from: msg.from };
|
|
817
805
|
}
|
|
818
|
-
|
|
819
|
-
await this.emitDeliveryStatus(messageId, readBy, "read", room);
|
|
820
|
-
// Propagate to other peers
|
|
821
|
-
await this.broadcastPatch(room
|
|
822
|
-
? { type: "message_read", messageId, readBy, room }
|
|
823
|
-
: { type: "message_read", messageId, readBy });
|
|
806
|
+
return undefined;
|
|
824
807
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const msg = msgs.find((m) => m.id === messageId);
|
|
838
|
-
if (msg && !msg.readBy.includes(readBy)) {
|
|
839
|
-
msg.readBy.push(readBy);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
808
|
+
/**
|
|
809
|
+
* 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.
|
|
810
|
+
*/
|
|
811
|
+
async markRead(messageId, readBy, room) {
|
|
812
|
+
const location = this.findMessageLocation(messageId, room);
|
|
813
|
+
if (location === undefined)
|
|
814
|
+
return;
|
|
815
|
+
const { roomPath, from } = location;
|
|
816
|
+
const history = room !== undefined ? this.messages.get(room) : this.dms.get(roomPath);
|
|
817
|
+
const message = history?.find((m) => m.id === messageId);
|
|
818
|
+
if (message && !message.readBy.includes(readBy)) {
|
|
819
|
+
message.readBy.push(readBy);
|
|
842
820
|
}
|
|
821
|
+
if (from === readBy)
|
|
822
|
+
return;
|
|
823
|
+
const { slot, clock } = this.requireIdentity();
|
|
824
|
+
const token = loadRoomTokens(slot)[roomPath];
|
|
825
|
+
if (token === undefined)
|
|
826
|
+
return;
|
|
827
|
+
const params = {
|
|
828
|
+
verb: "room.read",
|
|
829
|
+
messages: [bytesFromHex(messageId)],
|
|
830
|
+
at: clock.now(),
|
|
831
|
+
};
|
|
832
|
+
await this.sendRoomRequestToMember(from, roomPath, token, params);
|
|
843
833
|
}
|
|
844
834
|
// -----------------------------------------------------------------------
|
|
845
835
|
// CommsStore — Identity
|
|
@@ -969,6 +959,8 @@ export class MeshStore {
|
|
|
969
959
|
return {
|
|
970
960
|
"room.join": (request, handle) => this.handleRoomJoin(request, handle),
|
|
971
961
|
"room.send": (request, handle) => this.handleRoomSend(request, handle),
|
|
962
|
+
"room.read": (request, handle) => this.handleRoomRead(request, handle),
|
|
963
|
+
"room.members": (request, handle) => this.handleRoomMembers(request, handle),
|
|
972
964
|
};
|
|
973
965
|
}
|
|
974
966
|
/** 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. */
|
|
@@ -1052,6 +1044,94 @@ export class MeshStore {
|
|
|
1052
1044
|
this.fireLocalDelivery(this.peerId, event);
|
|
1053
1045
|
return { result: "ok" };
|
|
1054
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* 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.
|
|
1049
|
+
*/
|
|
1050
|
+
async handleRoomRead(request, handle) {
|
|
1051
|
+
const roomPath = request.scope.path;
|
|
1052
|
+
if (roomPath === undefined) {
|
|
1053
|
+
return { result: "error", code: "missing_scope_path" };
|
|
1054
|
+
}
|
|
1055
|
+
if (request.token === undefined) {
|
|
1056
|
+
return { result: "error", code: "unauthorized" };
|
|
1057
|
+
}
|
|
1058
|
+
const { identity, clock, revocation } = this.requireIdentity();
|
|
1059
|
+
const verdict = await verifyRoomToken(request.token, {
|
|
1060
|
+
identity,
|
|
1061
|
+
clock,
|
|
1062
|
+
revocation,
|
|
1063
|
+
expectedBearer: deviceIdFromHex(handle.id),
|
|
1064
|
+
roomPath,
|
|
1065
|
+
});
|
|
1066
|
+
if (!verdict.ok) {
|
|
1067
|
+
return { result: "error", code: "unauthorized" };
|
|
1068
|
+
}
|
|
1069
|
+
const parsedParams = roomReadSchema.safeParse(request.command.params);
|
|
1070
|
+
if (!parsedParams.success) {
|
|
1071
|
+
return { result: "error", code: "malformed_params" };
|
|
1072
|
+
}
|
|
1073
|
+
const params = parsedParams.data;
|
|
1074
|
+
const isDm = parseRoomPath(roomPath).kind === "dm";
|
|
1075
|
+
for (const messageIdBytes of params.messages) {
|
|
1076
|
+
const messageId = bytesToHex(messageIdBytes);
|
|
1077
|
+
const history = isDm
|
|
1078
|
+
? this.dms.get(roomPath)
|
|
1079
|
+
: this.messages.get(roomPath);
|
|
1080
|
+
const message = history?.find((m) => m.id === messageId);
|
|
1081
|
+
if (message === undefined)
|
|
1082
|
+
continue;
|
|
1083
|
+
if (!message.readBy.includes(handle.id)) {
|
|
1084
|
+
message.readBy.push(handle.id);
|
|
1085
|
+
}
|
|
1086
|
+
const event = {
|
|
1087
|
+
type: "delivery_status",
|
|
1088
|
+
messageId,
|
|
1089
|
+
agent: handle.id,
|
|
1090
|
+
status: "read",
|
|
1091
|
+
...(isDm ? {} : { room: roomPath }),
|
|
1092
|
+
};
|
|
1093
|
+
this.queueDelivery(this.peerId, event);
|
|
1094
|
+
this.fireLocalDelivery(this.peerId, event);
|
|
1095
|
+
}
|
|
1096
|
+
return { result: "ok" };
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* 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.
|
|
1100
|
+
*/
|
|
1101
|
+
async handleRoomMembers(request, handle) {
|
|
1102
|
+
const roomPath = request.scope.path;
|
|
1103
|
+
if (roomPath === undefined) {
|
|
1104
|
+
return { result: "error", code: "missing_scope_path" };
|
|
1105
|
+
}
|
|
1106
|
+
if (request.token === undefined) {
|
|
1107
|
+
return { result: "error", code: "unauthorized" };
|
|
1108
|
+
}
|
|
1109
|
+
const { identity, clock, revocation } = this.requireIdentity();
|
|
1110
|
+
const verdict = await verifyRoomToken(request.token, {
|
|
1111
|
+
identity,
|
|
1112
|
+
clock,
|
|
1113
|
+
revocation,
|
|
1114
|
+
expectedBearer: deviceIdFromHex(handle.id),
|
|
1115
|
+
roomPath,
|
|
1116
|
+
});
|
|
1117
|
+
if (!verdict.ok) {
|
|
1118
|
+
return { result: "error", code: "unauthorized" };
|
|
1119
|
+
}
|
|
1120
|
+
const parsedPath = parseRoomPath(roomPath);
|
|
1121
|
+
const room = this.rooms.get(roomPath);
|
|
1122
|
+
const members = parsedPath.kind === "dm"
|
|
1123
|
+
? parsedPath.participants.map((device) => ({
|
|
1124
|
+
device: deviceIdFromHex(device),
|
|
1125
|
+
}))
|
|
1126
|
+
: (room?.members ?? []).map((memberId) => ({
|
|
1127
|
+
device: deviceIdFromHex(memberId),
|
|
1128
|
+
}));
|
|
1129
|
+
return {
|
|
1130
|
+
result: "ok",
|
|
1131
|
+
members,
|
|
1132
|
+
...MeshStore.roomStateExtension(room),
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1055
1135
|
/**
|
|
1056
1136
|
* 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.
|
|
1057
1137
|
*/
|
|
@@ -1074,36 +1154,44 @@ export class MeshStore {
|
|
|
1074
1154
|
throw new CommsError(`room.send to ${memberId} for ${roomPath} failed (${outcome.code})`, "SEND_FAILED");
|
|
1075
1155
|
}
|
|
1076
1156
|
}
|
|
1077
|
-
/** Records a room
|
|
1078
|
-
|
|
1079
|
-
const queue = this.
|
|
1157
|
+
/** 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. */
|
|
1158
|
+
queuePendingRoomRequest(memberId, roomPath, params) {
|
|
1159
|
+
const queue = this.pendingRoomRequests.get(memberId) ?? [];
|
|
1080
1160
|
queue.push({ roomPath, params });
|
|
1081
1161
|
if (queue.length > MAX_QUEUED_DELIVERIES_PER_AGENT) {
|
|
1082
1162
|
queue.splice(0, queue.length - MAX_QUEUED_DELIVERIES_PER_AGENT);
|
|
1083
1163
|
}
|
|
1084
|
-
this.
|
|
1164
|
+
this.pendingRoomRequests.set(memberId, queue);
|
|
1085
1165
|
}
|
|
1086
1166
|
/**
|
|
1087
|
-
* Sends one directed room.send 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
|
|
1167
|
+
* 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.
|
|
1088
1168
|
*/
|
|
1089
|
-
async
|
|
1090
|
-
|
|
1169
|
+
async sendRoomRequestToMember(memberId, roomPath, token, params) {
|
|
1170
|
+
// 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.
|
|
1171
|
+
let outcome;
|
|
1172
|
+
try {
|
|
1173
|
+
outcome = await this.requireTransport().sendRoomRequest(memberId, { verb: ROOM_MEMBER_CAPABILITY, params }, { kind: "room", path: roomPath }, token);
|
|
1174
|
+
}
|
|
1175
|
+
catch {
|
|
1176
|
+
this.queuePendingRoomRequest(memberId, roomPath, params);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1091
1179
|
if (outcome.result !== "ok") {
|
|
1092
|
-
this.
|
|
1180
|
+
this.queuePendingRoomRequest(memberId, roomPath, params);
|
|
1093
1181
|
}
|
|
1094
1182
|
}
|
|
1095
1183
|
/** 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. */
|
|
1096
|
-
async
|
|
1097
|
-
const queue = this.
|
|
1184
|
+
async flushPendingRoomRequests(memberId) {
|
|
1185
|
+
const queue = this.pendingRoomRequests.get(memberId);
|
|
1098
1186
|
if (queue === undefined || queue.length === 0)
|
|
1099
1187
|
return;
|
|
1100
|
-
this.
|
|
1188
|
+
this.pendingRoomRequests.delete(memberId);
|
|
1101
1189
|
const { slot } = this.requireIdentity();
|
|
1102
1190
|
for (const pending of queue) {
|
|
1103
1191
|
const token = loadRoomTokens(slot)[pending.roomPath];
|
|
1104
1192
|
if (token === undefined)
|
|
1105
1193
|
continue;
|
|
1106
|
-
await this.
|
|
1194
|
+
await this.sendRoomRequestToMember(memberId, pending.roomPath, token, pending.params);
|
|
1107
1195
|
}
|
|
1108
1196
|
}
|
|
1109
1197
|
/**
|
|
@@ -1171,7 +1259,43 @@ export class MeshStore {
|
|
|
1171
1259
|
this.rooms.set(roomPath, room);
|
|
1172
1260
|
}
|
|
1173
1261
|
const members = (room?.members ?? [this.peerId, handle.id]).map((memberId) => ({ device: deviceIdFromHex(memberId) }));
|
|
1174
|
-
return {
|
|
1262
|
+
return {
|
|
1263
|
+
result: "ok",
|
|
1264
|
+
"granted-token": verdict.token,
|
|
1265
|
+
members,
|
|
1266
|
+
...MeshStore.roomStateExtension(room),
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* 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.
|
|
1271
|
+
*/
|
|
1272
|
+
static roomStateExtension(room) {
|
|
1273
|
+
if (room === undefined)
|
|
1274
|
+
return {};
|
|
1275
|
+
return {
|
|
1276
|
+
"room-state": {
|
|
1277
|
+
name: room.name,
|
|
1278
|
+
description: room.description,
|
|
1279
|
+
type: room.type,
|
|
1280
|
+
},
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
/** 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). */
|
|
1284
|
+
static parseRoomStateExtension(value) {
|
|
1285
|
+
if (typeof value !== "object" || value === null)
|
|
1286
|
+
return undefined;
|
|
1287
|
+
if (!("name" in value) || !("description" in value) || !("type" in value))
|
|
1288
|
+
return undefined;
|
|
1289
|
+
if (typeof value.name !== "string" || typeof value.description !== "string")
|
|
1290
|
+
return undefined;
|
|
1291
|
+
const parsedType = RoomType.safeParse(value.type);
|
|
1292
|
+
if (!parsedType.success)
|
|
1293
|
+
return undefined;
|
|
1294
|
+
return {
|
|
1295
|
+
name: value.name,
|
|
1296
|
+
description: value.description,
|
|
1297
|
+
type: parsedType.data,
|
|
1298
|
+
};
|
|
1175
1299
|
}
|
|
1176
1300
|
/** Every room.join request currently held open awaiting this store's own accept/reject decision. */
|
|
1177
1301
|
listPendingRoomJoins() {
|
|
@@ -1265,15 +1389,16 @@ export class MeshStore {
|
|
|
1265
1389
|
const { slot } = this.requireIdentity();
|
|
1266
1390
|
saveRoomToken(slot, roomPath, grantedToken);
|
|
1267
1391
|
const members = memberList.map((member) => deviceIdToHex(member.device));
|
|
1392
|
+
const roomState = MeshStore.parseRoomStateExtension(parsedOutcome.data["room-state"]);
|
|
1268
1393
|
const room = {
|
|
1269
1394
|
id: roomPath,
|
|
1270
1395
|
version: 1,
|
|
1271
|
-
name: parsed.localName,
|
|
1272
|
-
//
|
|
1273
|
-
type: "public",
|
|
1396
|
+
name: roomState?.name ?? parsed.localName,
|
|
1397
|
+
// 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).
|
|
1398
|
+
type: roomState?.type ?? "public",
|
|
1274
1399
|
owner: parsed.owner,
|
|
1275
1400
|
createdAt: new Date().toISOString(),
|
|
1276
|
-
description: "",
|
|
1401
|
+
description: roomState?.description ?? "",
|
|
1277
1402
|
members,
|
|
1278
1403
|
invited: [],
|
|
1279
1404
|
memberJoins: Object.fromEntries(members.map((member) => [member, 1])),
|
|
@@ -1286,6 +1411,49 @@ export class MeshStore {
|
|
|
1286
1411
|
this.messages.set(roomPath, []);
|
|
1287
1412
|
return room;
|
|
1288
1413
|
}
|
|
1414
|
+
/**
|
|
1415
|
+
* 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.
|
|
1416
|
+
*/
|
|
1417
|
+
async refreshRoomMembers(roomPath) {
|
|
1418
|
+
const parsed = parseRoomPath(roomPath);
|
|
1419
|
+
if (parsed.kind !== "owner-named") {
|
|
1420
|
+
throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
|
|
1421
|
+
}
|
|
1422
|
+
const { slot } = this.requireIdentity();
|
|
1423
|
+
const token = loadRoomTokens(slot)[roomPath];
|
|
1424
|
+
if (token === undefined) {
|
|
1425
|
+
throw new CommsError(`No room:member token for ${roomPath}`, "NOT_MEMBER");
|
|
1426
|
+
}
|
|
1427
|
+
const outcome = await this.requireTransport().sendRoomRequest(parsed.owner, { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.members" } }, { kind: "room", path: roomPath }, token);
|
|
1428
|
+
if (outcome.result !== "ok") {
|
|
1429
|
+
throw new CommsError(`room.members refresh for ${roomPath} failed (${outcome.code})`, "REFRESH_FAILED");
|
|
1430
|
+
}
|
|
1431
|
+
const parsedOutcome = roomMembersOkSchema.safeParse(outcome);
|
|
1432
|
+
if (!parsedOutcome.success) {
|
|
1433
|
+
throw new CommsError(`room.members response for ${roomPath} was malformed`, "MALFORMED_RESPONSE");
|
|
1434
|
+
}
|
|
1435
|
+
const members = parsedOutcome.data.members.map((member) => deviceIdToHex(member.device));
|
|
1436
|
+
const roomState = MeshStore.parseRoomStateExtension(parsedOutcome.data["room-state"]);
|
|
1437
|
+
const existing = this.rooms.get(roomPath);
|
|
1438
|
+
const room = {
|
|
1439
|
+
id: roomPath,
|
|
1440
|
+
version: (existing?.version ?? 0) + 1,
|
|
1441
|
+
name: roomState?.name ?? existing?.name ?? parsed.localName,
|
|
1442
|
+
type: roomState?.type ?? existing?.type ?? "public",
|
|
1443
|
+
owner: parsed.owner,
|
|
1444
|
+
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
1445
|
+
description: roomState?.description ?? existing?.description ?? "",
|
|
1446
|
+
members,
|
|
1447
|
+
invited: existing?.invited ?? [],
|
|
1448
|
+
memberJoins: Object.fromEntries(members.map((member) => [member, 1])),
|
|
1449
|
+
memberLeaves: {},
|
|
1450
|
+
invitedJoins: existing?.invitedJoins ?? {},
|
|
1451
|
+
invitedLeaves: existing?.invitedLeaves ?? {},
|
|
1452
|
+
federated: existing?.federated ?? false,
|
|
1453
|
+
};
|
|
1454
|
+
this.rooms.set(roomPath, room);
|
|
1455
|
+
return room;
|
|
1456
|
+
}
|
|
1289
1457
|
/**
|
|
1290
1458
|
* 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.
|
|
1291
1459
|
*/
|
|
@@ -1474,7 +1642,7 @@ export class MeshStore {
|
|
|
1474
1642
|
// CommsStore — Messages
|
|
1475
1643
|
// -----------------------------------------------------------------------
|
|
1476
1644
|
/**
|
|
1477
|
-
* 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
|
|
1645
|
+
* 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.
|
|
1478
1646
|
*/
|
|
1479
1647
|
async sendRoomMessage(roomId, from, content, replyTo, streamingBehavior) {
|
|
1480
1648
|
const room = this.rooms.get(roomId);
|
|
@@ -1520,7 +1688,7 @@ export class MeshStore {
|
|
|
1520
1688
|
};
|
|
1521
1689
|
for (const memberId of room.members) {
|
|
1522
1690
|
if (memberId !== from) {
|
|
1523
|
-
await this.
|
|
1691
|
+
await this.sendRoomRequestToMember(memberId, roomId, token, params);
|
|
1524
1692
|
}
|
|
1525
1693
|
}
|
|
1526
1694
|
return message;
|
|
@@ -1577,7 +1745,7 @@ export class MeshStore {
|
|
|
1577
1745
|
"streaming-behavior": streamingBehavior,
|
|
1578
1746
|
}),
|
|
1579
1747
|
};
|
|
1580
|
-
await this.
|
|
1748
|
+
await this.sendRoomRequestToMember(to, key, token, params);
|
|
1581
1749
|
}
|
|
1582
1750
|
return message;
|
|
1583
1751
|
}
|