Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions src/core/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,17 @@ export class AgentRegistry {
return agent;
}

/** Resolves an agent by id, falling back to a gossip-discovered device (see listDiscoverableAgents' own doc) not otherwise locally known -- the same merge listAgents already applies to its returned array, but for a single lookup rather than the whole list. This is what lets a caller resolve a remote, hub-learned agent (agent-comms#155) that will never appear in deps.agents at all, since nothing replicates full agent records cross-machine the way a local peer's agent_upsert broadcast does. */
async getAgent(id: string): Promise<AgentIdentity | undefined> {
await Promise.resolve();
return this.deps.agents.get(id);
const known = this.deps.agents.get(id);
if (known !== undefined) return known;
const discovered = this.listDiscoverableAgents().find(
(candidate) => candidate.deviceId === id,
);
return discovered === undefined
? undefined
: AgentRegistry.synthesiseDiscoveredAgent(discovered);
}

async updateAgent(
Expand Down Expand Up @@ -156,23 +164,32 @@ export class AgentRegistry {
}
for (const discovered of this.listDiscoverableAgents()) {
if (this.deps.agents.has(discovered.deviceId)) continue;
result.push({
id: discovered.deviceId,
version: 0,
name: discovered.advert.name,
harness: discovered.advert.harness,
cwd: discovered.advert.cwd,
pid: discovered.advert.pid,
startedAt: discovered.advert.startedAt,
visibility: "visible",
status: discovered.status ?? "active",
tags: discovered.advert.tags,
subscribedRooms: discovered.advert.subscribedRooms,
});
result.push(AgentRegistry.synthesiseDiscoveredAgent(discovered));
}
return result;
}

/** Builds the placeholder-shaped AgentIdentity a gossip-discovered device (never locally registered) is represented as -- shared by listAgents' own array merge and getAgent's single-lookup fallback, so both resolve an identical shape for the identical discovered device. */
private static synthesiseDiscoveredAgent(discovered: {
deviceId: string;
advert: AgentSelfAdvert;
status: AgentStatus | undefined;
}): AgentIdentity {
return {
id: discovered.deviceId,
version: 0,
name: discovered.advert.name,
harness: discovered.advert.harness,
cwd: discovered.advert.cwd,
pid: discovered.advert.pid,
startedAt: discovered.advert.startedAt,
visibility: "visible",
status: discovered.status ?? "active",
tags: discovered.advert.tags,
subscribedRooms: discovered.advert.subscribedRooms,
};
}

/**
* Every agent this store has heard gossiped by another device but never registered or otherwise locally recorded -- the read half of P3.8's eventual agent register/update/offline retirement (agent-comms#48), mirroring listRooms' own room-discovery merge (#138). Never merged into this.deps.agents: a gossip hint is not the same as a real registration, and this store has nothing else authoritative to report for it. Only ever an agent that gossiped itself as "visible" (MeshStore's own selfAgentAdvert getter never advertises a hidden or ghost agent this way), so no ghost-filtering is needed here the way listAgents' own local-agent check needs.
*/
Expand Down
17 changes: 16 additions & 1 deletion src/core/gossip-directory.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/**
* mergeKnownDevices — the mesh-wide gossip-directory aggregation WireMeshTransport's own listKnownDevices reads from. Split out purely to keep wire-mesh-transport.ts under the repo's max-lines cap, the same reason connection-approval.ts, room-router.ts, hub-session.ts, and peer-lifecycle.ts were each split from their own owning file.
* gossip-directory — the mesh-wide gossip-directory aggregation WireMeshTransport's own listKnownDevices reads from, plus per-event directory lookups (a specific peer's presence advert). Split out purely to keep wire-mesh-transport.ts under the repo's max-lines cap, the same reason connection-approval.ts, room-router.ts, hub-session.ts, and peer-lifecycle.ts were each split from their own owning file.
*/

import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session";
import type { PeerAdvert } from "wire-mesh-core/generated/protocol";
import { AgentStatus } from "./types.js";
import { PRESENCE_GOSSIP_KEY } from "./wire-mesh-transport.js";

/** Merges one session event's own directory into the mesh-wide knownDevices view (mutated in place), keeping the newer advert (by snapshot-seconds) whenever a device-id is already known from an earlier event or a different session. */
export function mergeKnownDevices(
Expand All @@ -22,3 +24,16 @@ export function mergeKnownDevices(
}
}
}

/** Reads a presence extension from one specific device's own gossiped self-advert, if this event's directory carries a fresh one for exactly that device-id -- never for any other device-id a multi-hop directory might mention, since only the session's own authenticated peer's advert is that session's business to report. Returns undefined for a missing presence/status key, or a value that isn't a recognised AgentStatus -- an advert simply not participating in this convention, not an error (the same verifier obligation peer-advert's own open extension tail is documented under). */
export function findPresenceAdvert(
deviceIdHex: string,
directory: readonly DirectoryEntry[],
): AgentStatus | undefined {
const entry = directory.find(
(candidate) => deviceIdToHex(candidate.device) === deviceIdHex,
);
if (entry === undefined) return undefined;
const status: unknown = entry.advert[PRESENCE_GOSSIP_KEY];
return AgentStatus.is(status) ? status : undefined;
}
57 changes: 57 additions & 0 deletions src/core/hub-forwarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* hub-forwarding -- agent-comms#155's gateway forwarding, split from wire-mesh-transport.ts under the repo's max-lines cap. Outbound: filters a directory of gossiped local devices down to the ones eligible for cross-machine advertisement (an agent/self-bearing entry, meaning only ever a "visible" agent -- see AGENT_SELF_GOSSIP_KEY's own doc for why no separate visibility check is needed here) and pushes them onto a connected HubSession, best-effort. Local-to-remote routing: falls a room-domain request back through the hub's own relay-connect/relay-data pairing when its target isn't a local peer session.
*/

import type {
CapabilityScope,
CapabilityToken,
ManageCommand,
PeerAdvert,
} from "wire-mesh-core/generated/protocol";
import type {
DirectoryEntry,
ManageOutcome,
} from "wire-mesh-core/domain/mesh-session";
import type { HubSession } from "./hub-session.js";
import { AGENT_SELF_GOSSIP_KEY } from "./wire-mesh-transport.js";

/** Forwards every directory entry carrying an agent/self extension onto hub, if hub currently holds a live connection -- a no-op otherwise, which is every local peer except whichever one is currently the gateway. Called both from WireMeshTransport's own watchForDisconnect (as each local peer session's directory changes) and from connectHub's own initial catch-up push, so this side's already-known local devices reach the hub immediately on taking over the gateway role rather than waiting for their own next periodic gossip tick. Forwards unconditionally on every call (no caching or dedup against a previous call), mirroring relay-hub.ts's own "forward every gossip frame, as received" philosophy: an advert's own snapshot-seconds/addresses are meant to keep propagating as a liveness heartbeat, so suppressing a "duplicate" would silently stop legitimate freshness updates from reaching remote gateways. A forward that fails is reported via onError and swallowed, matching every other best-effort gossip send in this codebase. */
export function forwardAdvertsToHub(
hub: Readonly<Pick<HubSession, "isConnected" | "advertiseDevices">>,
directory: readonly DirectoryEntry[],
onError: ((error: Error) => void) | undefined,
): void {
if (!hub.isConnected) return;
const eligible = directory.filter(
(entry) => entry.advert[AGENT_SELF_GOSSIP_KEY] !== undefined,
);
if (eligible.length === 0) return;
hub.advertiseDevices(eligible).catch((error: unknown) => {
onError?.(error instanceof Error ? error : new Error(String(error)));
});
}

/** Catches the hub up with every local device already known at the moment this side becomes the gateway (connectHub's own trailing call, right after hub.connect resolves) -- without this, a device whose own last gossip arrived before this coordinator took over the gateway role would never be (re-)advertised until its own next periodic gossip tick (hub-side state is rebuilt from scratch on every takeover, per coordinator-gateway.ts's own class doc). Reuses forwardAdvertsToHub's own eligibility filter, so only ever a visible, agent/self-bearing device is pushed, exactly as an ordinary directory-change forward would. */
export function pushHubCatchUp(
hub: Readonly<Pick<HubSession, "isConnected" | "advertiseDevices">>,
knownDevices: ReadonlyMap<string, Readonly<PeerAdvert>>,
onError: ((error: Error) => void) | undefined,
): void {
const catchUp = [...knownDevices.values()].map((advert) => ({
device: advert.device,
advert,
}));
forwardAdvertsToHub(hub, catchUp, onError);
}

/** Routes a room-domain request through the hub's relay-connect/relay-data pairing when memberId isn't a local peer session -- WireMeshTransport.sendRoomRequest's own fallback, since the member may be a remote agent reachable only via this machine's gateway connection (agent-comms#155's local-to-remote leg). Resolves the same not_connected outcome sendRoomRequest already returned before the hub existed at all when this side isn't currently the gateway. */
export async function routeRoomRequestViaHub(
hub: Readonly<Pick<HubSession, "isConnected" | "sendRoomRequest">>,
memberId: string,
command: ManageCommand,
scope: Readonly<CapabilityScope>,
token?: CapabilityToken,
): Promise<ManageOutcome> {
if (!hub.isConnected) return { result: "error", code: "not_connected" };
return hub.sendRoomRequest(memberId, command, scope, token);
}
Loading
Loading