From 5b80a6fcc7b217b94be0a58fca0550ddf7cf560e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 04:19:05 +0100 Subject: [PATCH] feat(core): merge gossip-discovered rooms into listRooms listRooms now also surfaces every public/private room this store has heard gossiped by another device (via room/hosted, #131/#132) but never joined or otherwise locally recorded, mixed into its existing Room[] result as a display-only placeholder -- id/name/type/owner/ description real, everything membership-shaped (members, memberJoins, etc.) genuinely empty, since a gossip hint is not the same as admission and this store has nothing else real to report for it. A room already known locally always wins; a gossip advert for it is silently ignored rather than shadowing the real record. MeshTransport gains an optional listKnownDevices method (matching WireMeshTransport's own already-shipped implementation) so RoomLifecycle can read it through the existing abstract requireTransport() port rather than depending on the concrete WireMeshTransport type -- absent for any transport implementation that doesn't offer it, in which case the merge step is simply a no-op, matching every construction site that predates this feature. A gossiped candidate is narrowed through a real type guard (isHostedRoomAdvert) before use, matching the codebase's own "an advert not participating in this convention is not an error" gossip-consumption pattern already established for presence/status. --- src/core/room-lifecycle.ts | 54 +++++++++++++++++++++++++ src/core/transport.ts | 8 ++++ src/test/room-lifecycle.test.ts | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/src/core/room-lifecycle.ts b/src/core/room-lifecycle.ts index 5d82e22d..ad91b457 100644 --- a/src/core/room-lifecycle.ts +++ b/src/core/room-lifecycle.ts @@ -41,6 +41,7 @@ import { import type { DeliveryEngine } from "./delivery-engine.js"; import type { FederationManager } from "./federation.js"; import type { MeshTransport } from "./transport.js"; +import type { HostedRoomAdvert } from "./wire-mesh-transport.js"; import type { AgentIdentity, AgentStatus, @@ -49,6 +50,21 @@ import type { RoomType, } from "./types.js"; +/** Narrows an untrusted gossiped value (WireMeshTransport.listKnownDevices' own advert["room/hosted"], self-asserted by whichever peer advertised it) into a HostedRoomAdvert -- a malformed or non-conforming entry is silently skipped rather than treated as an error, the same convention presence/status' own gossip consumption already established: this is a discovery hint over self-asserted data, not a security check. */ +function isHostedRoomAdvert(value: unknown): value is HostedRoomAdvert { + if (typeof value !== "object" || value === null) return false; + if (!("path" in value) || typeof value.path !== "string") return false; + if (!("name" in value) || typeof value.name !== "string") return false; + if ( + !("type" in value) || + (value.type !== "public" && value.type !== "private") + ) + return false; + if (!("description" in value) || typeof value.description !== "string") + return false; + return true; +} + /** The state and collaborators RoomLifecycle needs from MeshStore. rooms/messages/agents/dmRequestsInitiatedByMe are direct references into MeshStore's own fields (dmRequestsInitiatedByMe shared with RoomProtocol, which reads what requestDmAccess writes here); deliveryEngine and federation are the already-constructed instances, narrowed to what room CRUD ever needs. */ export interface RoomLifecycleDeps { rooms: Map; @@ -160,6 +176,44 @@ export class RoomLifecycle { continue; result.push(room); } + for (const discovered of this.listDiscoverableRooms()) { + if (this.deps.rooms.has(discovered.path)) continue; + result.push({ + id: discovered.path, + version: 0, + name: discovered.name, + type: discovered.type, + owner: discovered.ownerDeviceId, + createdAt: "", + description: discovered.description, + members: [], + invited: [], + memberJoins: {}, + memberLeaves: {}, + invitedJoins: {}, + invitedLeaves: {}, + }); + } + return result; + } + + /** + * Every public/private room this store has heard gossiped by another device but never joined or otherwise locally recorded -- the read half of P3.8's room-discovery replacement for createRoom's own broadcastPatch (agent-comms#48/#50). Never merged into this.deps.rooms: a gossip hint is not membership, and a room this store was never admitted to has nothing real to synthesize beyond what the advert itself carries. Secret rooms never need filtering here the way listRooms' own local-room check needs -- HostedRoomAdvert's own type field is restricted to "public" | "private" at the source (WireMeshTransport's getHostedRooms), so a secret room is never gossiped under this key at all. + */ + private listDiscoverableRooms(): readonly (HostedRoomAdvert & { + ownerDeviceId: string; + })[] { + const transport = this.deps.requireTransport(); + if (transport.listKnownDevices === undefined) return []; + const result: (HostedRoomAdvert & { ownerDeviceId: string })[] = []; + for (const { deviceId, advert } of transport.listKnownDevices()) { + const hosted = advert["room/hosted"]; + if (!Array.isArray(hosted)) continue; + for (const candidate of hosted) { + if (!isHostedRoomAdvert(candidate)) continue; + result.push({ ...candidate, ownerDeviceId: deviceId }); + } + } return result; } diff --git a/src/core/transport.ts b/src/core/transport.ts index f2841717..f3763564 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -251,4 +251,12 @@ export interface MeshTransport { * Unref all root handles so the event loop can exit when the agent process shuts down. Sockets still function for I/O but don't keep the process alive. */ unref: () => void; + + /** + * Every device this side has ever heard gossip from, mesh-wide, with each one's own latest full advert (any open-extension field such as room/hosted). Optional: WireMeshTransport is the only implementation that offers it today (agent-comms#48/#50's own gossip-directory aggregation), so a caller (listRooms' own room-discovery merge) must treat its absence as "nothing to merge," never assume every MeshTransport has it. + */ + listKnownDevices?: () => readonly { + deviceId: string; + advert: Readonly>; + }[]; } diff --git a/src/test/room-lifecycle.test.ts b/src/test/room-lifecycle.test.ts index 98849ac2..24b04a39 100644 --- a/src/test/room-lifecycle.test.ts +++ b/src/test/room-lifecycle.test.ts @@ -369,6 +369,78 @@ describe("RoomLifecycle — listRooms", () => { const result = await h.lifecycle.listRooms(h.ids.memberId); expect(result.map((r) => r.id)).toEqual(["sec"]); }); + + it("merges in a gossip-discovered room not otherwise locally known, as a placeholder-shaped Room", async () => { + const h = await makeHarness(); + h.deps.requireTransport = () => + ({ + listKnownDevices: () => [ + { + deviceId: h.ids.ownerId, + advert: { + "room/hosted": [ + { + path: "discovered-room", + name: "discovered", + type: "public", + description: "found via gossip", + }, + ], + }, + }, + ], + }) as unknown as ReturnType; + + const result = await h.lifecycle.listRooms(h.ids.memberId); + const discovered = result.find((r) => r.id === "discovered-room"); + expect(discovered).toMatchObject({ + id: "discovered-room", + name: "discovered", + type: "public", + owner: h.ids.ownerId, + description: "found via gossip", + members: [], + }); + }); + + it("never lets a gossip-discovered room shadow a room this store already knows locally", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "already-known", + room({ id: "already-known", type: "public", description: "real" }), + ); + h.deps.requireTransport = () => + ({ + listKnownDevices: () => [ + { + deviceId: h.ids.ownerId, + advert: { + "room/hosted": [ + { + path: "already-known", + name: "stale-gossip-copy", + type: "public", + description: "gossip", + }, + ], + }, + }, + ], + }) as unknown as ReturnType; + + const result = await h.lifecycle.listRooms(h.ids.memberId); + expect(result.filter((r) => r.id === "already-known")).toHaveLength(1); + expect(result.find((r) => r.id === "already-known")?.description).toBe( + "real", + ); + }); + + it("ignores a transport with no listKnownDevices capability, matching every construction site that predates this feature", async () => { + const h = await makeHarness(); + h.deps.rooms.set("pub", room({ id: "pub", type: "public" })); + const result = await h.lifecycle.listRooms(h.ids.memberId); + expect(result.map((r) => r.id)).toEqual(["pub"]); + }); }); // joinRoom's own `this.deps.rooms.set(roomId, room)` and `this.deps.agents.set(agentId, agent)` calls each have one provable equivalent mutant Stryker still raises: removing them. Both `room` and `agent` are the same object references already fetched via `.get()`, and every mutation up to each call (bump/recordMemberOp/refreshMembership for room; push/bump for agent) already happened in place on that reference -- so re-setting the map entry to the identical reference it already holds changes nothing observable, the same Map.set-same-reference pattern documented throughout this repo's own mutation-testing work (agent-registry.ts's setAgentOffline, delivery-engine.ts's applyPatch(agent_offline)).