agent-comms 8.15.9 → 8.15.11
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/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +44 -24
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
- package/dist/bridges/user/web/web-assets.d.ts +28 -0
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
- package/dist/bridges/user/web/web-assets.js +67 -0
- package/dist/bridges/user/web/web-assets.js.map +1 -0
- package/dist/test/pi-manifest-packed.integration.test.d.ts +2 -0
- package/dist/test/pi-manifest-packed.integration.test.d.ts.map +1 -0
- package/dist/test/pi-manifest-packed.integration.test.js +68 -0
- package/dist/test/pi-manifest-packed.integration.test.js.map +1 -0
- package/package.json +8 -2
- package/src/account-cli.ts +195 -0
- package/src/account-join-cli.ts +191 -0
- package/src/bridges/cc-peer/approval-commands.ts +82 -0
- package/src/bridges/cc-peer/bridge.ts +138 -0
- package/src/bridges/cc-peer/default-front.ts +58 -0
- package/src/bridges/cc-peer/front-controller.ts +160 -0
- package/src/bridges/cc-peer/front-relay.ts +191 -0
- package/src/bridges/cc-peer/front-runtime.ts +167 -0
- package/src/bridges/cc-peer/front.ts +59 -0
- package/src/bridges/cc-peer/reply-aliases.ts +90 -0
- package/src/bridges/cc-peer/run.ts +100 -0
- package/src/bridges/cc-peer/shared-peer.ts +28 -0
- package/src/bridges/claude-code/channel.ts +318 -0
- package/src/bridges/codex/tool.ts +132 -0
- package/src/bridges/mcp/server.ts +137 -0
- package/src/bridges/opencode/plugin.ts +130 -0
- package/src/bridges/pi/extension.ts +330 -0
- package/src/bridges/registry.ts +17 -0
- package/src/bridges/user/cli.ts +87 -0
- package/src/bridges/user/controller.ts +345 -0
- package/src/bridges/user/tui.ts +300 -0
- package/src/bridges/user/web/actions.ts +173 -0
- package/src/bridges/user/web/contract.ts +222 -0
- package/src/bridges/user/web/dist/bundle.js +49 -0
- package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
- package/src/bridges/user/web/dist/index.html +21 -0
- package/src/bridges/user/web/dist/manifest.json +26 -0
- package/src/bridges/user/web/dist/mesh-worker.js +64 -0
- package/src/bridges/user/web/dist/sw.js +1 -0
- package/src/bridges/user/web/event-publisher.ts +114 -0
- package/src/bridges/user/web/port-discovery.ts +49 -0
- package/src/bridges/user/web/reads.ts +35 -0
- package/src/bridges/user/web/router.ts +158 -0
- package/src/bridges/user/web/server.ts +782 -0
- package/src/bridges/user/web/vite.config.ts +71 -0
- package/src/bridges/user/web/web-access.ts +404 -0
- package/src/bridges/user/web/web-assets.ts +90 -0
- package/src/bridges/user/web/web-beacon.ts +69 -0
- package/src/bridges/user/web/web-console-static.ts +90 -0
- package/src/cli-main.ts +635 -0
- package/src/cli.ts +13 -0
- package/src/core/account-bundle.ts +264 -0
- package/src/core/account-join.ts +289 -0
- package/src/core/account-ledger-crypto.ts +154 -0
- package/src/core/account-ledger-store.ts +66 -0
- package/src/core/account-ledger.ts +385 -0
- package/src/core/agent-details.ts +216 -0
- package/src/core/agent-list-format.ts +97 -0
- package/src/core/agent-registry.ts +282 -0
- package/src/core/agent-table.ts +58 -0
- package/src/core/atomic-file.ts +46 -0
- package/src/core/bind-retry.ts +38 -0
- package/src/core/bridge-mesh.ts +176 -0
- package/src/core/bridge.ts +718 -0
- package/src/core/capability-ask.ts +162 -0
- package/src/core/capability-bubble-up.ts +237 -0
- package/src/core/comms-store.ts +130 -0
- package/src/core/connection-approval.ts +134 -0
- package/src/core/connection-code-tool.ts +92 -0
- package/src/core/connection-code.ts +296 -0
- package/src/core/coordinator-role.ts +224 -0
- package/src/core/data-directory.ts +32 -0
- package/src/core/data-frame-routing.ts +309 -0
- package/src/core/default-command.ts +12 -0
- package/src/core/delegation-policy.ts +41 -0
- package/src/core/delivery-engine.ts +649 -0
- package/src/core/device-membership-verification.ts +125 -0
- package/src/core/device-membership.ts +130 -0
- package/src/core/directory-admission.ts +155 -0
- package/src/core/discovery-mdns.ts +247 -0
- package/src/core/discovery-tailscale.ts +224 -0
- package/src/core/discovery.ts +303 -0
- package/src/core/display-name.ts +73 -0
- package/src/core/dm-grant-actions.ts +72 -0
- package/src/core/dm-send-delegation.ts +73 -0
- package/src/core/dm-token-verification.ts +76 -0
- package/src/core/election-sessions.ts +135 -0
- package/src/core/error-reporting.ts +85 -0
- package/src/core/first-contact.ts +242 -0
- package/src/core/gateway-trust-actions.ts +184 -0
- package/src/core/gateway-trust.ts +305 -0
- package/src/core/gossip-directory.ts +276 -0
- package/src/core/gossip-extensions.ts +123 -0
- package/src/core/group-proofs.ts +205 -0
- package/src/core/handshake.ts +354 -0
- package/src/core/hub-forwarding.ts +24 -0
- package/src/core/hub-link.ts +176 -0
- package/src/core/hub-session.ts +374 -0
- package/src/core/identity-store.ts +715 -0
- package/src/core/identity.ts +489 -0
- package/src/core/index.ts +65 -0
- package/src/core/issuer-grouping.ts +165 -0
- package/src/core/issuer-identity-file.ts +187 -0
- package/src/core/lan-web-actions.ts +34 -0
- package/src/core/lan-web-uis.ts +55 -0
- package/src/core/ledger-lock.ts +81 -0
- package/src/core/listener-registry.ts +133 -0
- package/src/core/loopback-address.ts +22 -0
- package/src/core/machine-identity.ts +77 -0
- package/src/core/machine-list-groups.ts +53 -0
- package/src/core/mcp-registry-retry.ts +69 -0
- package/src/core/mcp-registry-version.ts +18 -0
- package/src/core/membership-proof.ts +139 -0
- package/src/core/membership-proofs.ts +149 -0
- package/src/core/mesh-graph-trace-tool.ts +81 -0
- package/src/core/mesh-graph.ts +89 -0
- package/src/core/mesh-network-actions.ts +183 -0
- package/src/core/mesh-store-options.ts +23 -0
- package/src/core/mesh-store-shared.ts +134 -0
- package/src/core/mesh-store.ts +1114 -0
- package/src/core/name-claim.ts +145 -0
- package/src/core/naming-actions.ts +90 -0
- package/src/core/naming.ts +138 -0
- package/src/core/nanoid.ts +20 -0
- package/src/core/network-interfaces.ts +24 -0
- package/src/core/package-version.ts +34 -0
- package/src/core/peer-lifecycle.ts +377 -0
- package/src/core/peer-link-error.ts +41 -0
- package/src/core/peer-versions.ts +98 -0
- package/src/core/pending-connection.ts +53 -0
- package/src/core/petnames.ts +121 -0
- package/src/core/pgp-keyserver.ts +32 -0
- package/src/core/pid-lock-file.ts +122 -0
- package/src/core/push-manager.ts +76 -0
- package/src/core/random-id.ts +9 -0
- package/src/core/receiver-set.ts +42 -0
- package/src/core/relay-role.ts +182 -0
- package/src/core/relay-selection.ts +120 -0
- package/src/core/relay-server.ts +198 -0
- package/src/core/relay-uplink.ts +78 -0
- package/src/core/request-timeouts.ts +37 -0
- package/src/core/room-discovery.ts +48 -0
- package/src/core/room-lifecycle.ts +838 -0
- package/src/core/room-lookup.ts +41 -0
- package/src/core/room-messaging.ts +247 -0
- package/src/core/room-notice-log.ts +61 -0
- package/src/core/room-path.ts +84 -0
- package/src/core/room-protocol.ts +893 -0
- package/src/core/room-router.ts +180 -0
- package/src/core/room-token-verification.ts +85 -0
- package/src/core/room-wire-extensions.ts +60 -0
- package/src/core/self-name-claim.ts +67 -0
- package/src/core/send-outcome.ts +69 -0
- package/src/core/shutdown-signals.ts +57 -0
- package/src/core/stale-agent-checker.ts +108 -0
- package/src/core/store.ts +704 -0
- package/src/core/token-text.ts +40 -0
- package/src/core/tool.ts +853 -0
- package/src/core/transport-events.ts +73 -0
- package/src/core/transport-queries.ts +54 -0
- package/src/core/transport.ts +367 -0
- package/src/core/types.ts +550 -0
- package/src/core/user-identity.ts +290 -0
- package/src/core/vapid.ts +81 -0
- package/src/core/version-check.ts +147 -0
- package/src/core/version-report-actions.ts +79 -0
- package/src/core/web-beacon.ts +31 -0
- package/src/core/web-push.ts +322 -0
- package/src/core/wire-mesh-core-version.ts +25 -0
- package/src/core/wire-mesh-identity.ts +36 -0
- package/src/core/wire-mesh-transport-options.ts +82 -0
- package/src/core/wire-mesh-transport.ts +1007 -0
- package/src/core/wire-protocol.ts +153 -0
- package/src/core/ws-dial.ts +191 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerLifecycle: the TransportEvents callbacks around a peer's own comings and goings on the mesh: peer-list/peer-joined bookkeeping, the well-known port holder's new-peer introduction handshake, post-connection state-sync, claiming the elected coordinator role when this store is alone, inbound data-message routing (state_sync/state_update), taking over the well-known port listener, and peer disconnection. Split out of mesh-store.ts to reduce it under the repo's max-lines cap.
|
|
3
|
+
*
|
|
4
|
+
* Two roles are kept apart here (agent-comms#341). The well-known port listener is only the compatibility first-contact address and introduction path, and is still passed on by graceful handover and recovered by the bind race. The coordinator duties (the stale-agent probe, single-authority departure announcements, the default cc-peer front) follow the elected CoordinatorRole instead, whose holder need not be the peer that bound the port.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { normaliseWireState } from "./wire-protocol.js";
|
|
8
|
+
import type {
|
|
9
|
+
MeshMessage,
|
|
10
|
+
PeerInfo,
|
|
11
|
+
SerialisedState,
|
|
12
|
+
} from "./wire-protocol.js";
|
|
13
|
+
import {
|
|
14
|
+
BECOME_COORDINATOR_BIND_RETRIES,
|
|
15
|
+
BECOME_COORDINATOR_BIND_RETRY_DELAY_MS,
|
|
16
|
+
isAddrInUse,
|
|
17
|
+
isConnectionRefused,
|
|
18
|
+
} from "./bind-retry.js";
|
|
19
|
+
import { ROOM_REQUEST_TIMEOUT_MS } from "./request-timeouts.js";
|
|
20
|
+
import { COORDINATOR_HOST } from "./mesh-store-shared.js";
|
|
21
|
+
import { startFirstContact, type FirstContact } from "./first-contact.js";
|
|
22
|
+
import type { DeliveryEngine } from "./delivery-engine.js";
|
|
23
|
+
import type { RoomProtocol } from "./room-protocol.js";
|
|
24
|
+
import type { CoordinatorRole } from "./coordinator-role.js";
|
|
25
|
+
import type { ConnectionHandle, MeshTransport } from "./transport.js";
|
|
26
|
+
import type { AgentIdentity } from "./types.js";
|
|
27
|
+
|
|
28
|
+
/** The state and collaborators PeerLifecycle needs from MeshStore. peerInfo/agents are direct references into MeshStore's own fields; coordinatorPort is a readonly value copied once; serialise is MeshStore's own retained method (constraint: it must stay directly on MeshStore.prototype, so PeerLifecycle calls it through this closure rather than owning it); roomProtocol/deliveryEngine/staleAgentChecker are the already-constructed instances (construction order: ... -\> roomProtocol -\> ... -\> staleAgentChecker -\> peerLifecycle), narrowed to what peer-lifecycle bookkeeping ever needs. */
|
|
29
|
+
export interface PeerLifecycleDeps {
|
|
30
|
+
peerInfo: Map<string, PeerInfo>;
|
|
31
|
+
agents: Map<string, AgentIdentity>;
|
|
32
|
+
coordinatorPort: number;
|
|
33
|
+
/** The UDP port the coordinator-free first-contact presence binds (agent-comms#341). Undefined means no presence at all: only the bridge entry points set it (bridge-mesh.ts), so a store built by anything else, a test included, never cross-discovers another store. */
|
|
34
|
+
firstContactPort?: number | undefined;
|
|
35
|
+
/** Receives every web UI beacon the presence hears (agent-comms#353). Absent when the caller wants none. */
|
|
36
|
+
onWebBeacon?: (
|
|
37
|
+
beacon: Readonly<{ peerId: string; host: string; webPort: number }>,
|
|
38
|
+
) => void;
|
|
39
|
+
getPeerId: () => string;
|
|
40
|
+
/** The peer ID of the coordinator this side currently answers to, as the transport reports it -- undefined when this side is the coordinator itself or has never reached one. Supplied as its own dep rather than read off requireTransport() so the crash-race decision below is expressible against a plain value in tests. */
|
|
41
|
+
getCoordinatorPeerId: () => string | undefined;
|
|
42
|
+
requireTransport: () => MeshTransport;
|
|
43
|
+
serialise: () => SerialisedState;
|
|
44
|
+
roomProtocol: Pick<RoomProtocol, "flushPendingRoomRequests">;
|
|
45
|
+
deliveryEngine: Pick<
|
|
46
|
+
DeliveryEngine,
|
|
47
|
+
| "applyStateSync"
|
|
48
|
+
| "applyPatch"
|
|
49
|
+
| "notifyRoomsOfStatus"
|
|
50
|
+
| "broadcastPatch"
|
|
51
|
+
| "bump"
|
|
52
|
+
>;
|
|
53
|
+
/** The elected coordinator role: claimed when this side takes the port listener, or finds it held by something that never answers, with no incumbent known; recovered when its holder departs; and the authority retireDepartedAgent checks. Announcing it to each new session is the transport's onElectionSessionEnrolled, not a PeerLifecycle concern. */
|
|
54
|
+
coordinatorRole: Pick<
|
|
55
|
+
CoordinatorRole,
|
|
56
|
+
"claimIfVacant" | "claimIfVacantAfterWait" | "handleDeparture" | "isHolder"
|
|
57
|
+
>;
|
|
58
|
+
/** Reports a failed takeover or rejoin. The crash-race path runs detached from any caller that could handle a rejection, so this is the only signal that a coordinator loss was noticed but not recovered from. */
|
|
59
|
+
onError?: ((error: Error) => void) | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The message an unknown thrown value is reported as, so a takeover or rejoin failure reads the same whether the transport rejected with an Error or something else. */
|
|
63
|
+
function describe(error: unknown): string {
|
|
64
|
+
return error instanceof Error ? error.message : String(error);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** How many times a peer that has lost its coordinator alternates between binding the vacated port and joining whoever bound it first. A round that loses the bind has already spent a full becomeCoordinator retry window (BECOME_COORDINATOR_BIND_RETRIES attempts, BECOME_COORDINATOR_BIND_RETRY_DELAY_MS apart), so the rounds together are bounded by ROOM_REQUEST_TIMEOUT_MS, the ordinary bound on a peer completing a step. Several rounds are genuinely needed because the departed holder's own listener can outlast the sessions that reported its departure: its close only finishes once its transport has closed every session, which under load takes far longer than one retry window, and every bind and dial in that interval meets the dying listener. */
|
|
68
|
+
const COORDINATOR_TAKEOVER_ROUNDS = Math.ceil(
|
|
69
|
+
ROOM_REQUEST_TIMEOUT_MS /
|
|
70
|
+
(BECOME_COORDINATOR_BIND_RETRIES * BECOME_COORDINATOR_BIND_RETRY_DELAY_MS),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
/** What one attempt to join whoever won the bind race concluded. "unsettled" means no successor holds the port yet, so the caller re-enters the bind race: nothing was listening (the port was taken when this side tried to bind it and free again by the time it tried to dial), the dial reached the lost holder's own listener while it was still closing (told apart by the device-id the dial authenticated), or the dial failed on a connection that was torn down mid-handshake, which says the same thing. `cause` is the dial failure, when there was one, so that giving up can say what the last attempt met. */
|
|
74
|
+
type RejoinOutcome =
|
|
75
|
+
{ outcome: "joined" } | { outcome: "unsettled"; cause?: Error };
|
|
76
|
+
|
|
77
|
+
export class PeerLifecycle {
|
|
78
|
+
/** Guards against a second contest running while the first is still binding or rejoining -- a coordinator whose process dies can close more than one session to this side, and each close arrives as its own disconnect. */
|
|
79
|
+
private contestingCoordinatorRole = false;
|
|
80
|
+
|
|
81
|
+
/** The first-contact presence, when one was configured and started; owned here because discovering a peer and dialling it is peer-lifecycle bookkeeping (handlePeerList), the same path a coordinator's peer_list takes. */
|
|
82
|
+
private firstContact: FirstContact | undefined;
|
|
83
|
+
|
|
84
|
+
constructor(private readonly deps: PeerLifecycleDeps) {}
|
|
85
|
+
|
|
86
|
+
/** Starts the coordinator-free presence (agent-comms#341) when a firstContactPort was configured: each discovered peer (carrying the host its beacon came from) is fed through handlePeerList, whose dial-out plus the peer_list every established connection shares (handlePeerConnected) forms the mesh transitively with no coordinator's handout. A no-op with no port, and on a second call. */
|
|
87
|
+
startFirstContact(): void {
|
|
88
|
+
const port = this.deps.firstContactPort;
|
|
89
|
+
if (port === undefined || this.firstContact !== undefined) return;
|
|
90
|
+
this.firstContact = startFirstContact({
|
|
91
|
+
peerId: this.deps.getPeerId(),
|
|
92
|
+
dataPort: this.deps.requireTransport().dataPort,
|
|
93
|
+
port,
|
|
94
|
+
onWebBeacon: this.deps.onWebBeacon,
|
|
95
|
+
onPeer: (info) => {
|
|
96
|
+
this.handlePeerList([info]);
|
|
97
|
+
},
|
|
98
|
+
onError: (error) => {
|
|
99
|
+
this.deps.onError?.(error);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Stops the presence and releases its port. Idempotent, and a no-op when none was started. */
|
|
105
|
+
stopFirstContact(): void {
|
|
106
|
+
this.firstContact?.stop();
|
|
107
|
+
this.firstContact = undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* init()'s single attempt at the well-known port: connect to whoever holds it, or bind it through the same takeover a handover or crash race runs (which, knowing of no elected incumbent, claims the coordinator role as the first store on this machine as far as it can tell). If the port is occupied but unresponsive (e.g. an orphan process from a previous session), carry on without it instead of retrying: first contact still forms the mesh, and with nothing answering on the port this store claims the coordinator role the same way, unless first contact brings it an incumbent's claim first. Retrying tls.connect after a failed handshake to a non-TLS endpoint can freeze the event loop (Node.js TLS session cache bug), so this only tries once. A bind failure other than EADDRINUSE is rethrown.
|
|
112
|
+
*/
|
|
113
|
+
async joinWellKnownPort(): Promise<void> {
|
|
114
|
+
const transport = this.deps.requireTransport();
|
|
115
|
+
const port = this.deps.coordinatorPort;
|
|
116
|
+
try {
|
|
117
|
+
await transport.connectToCoordinator(
|
|
118
|
+
COORDINATOR_HOST,
|
|
119
|
+
port,
|
|
120
|
+
this.deps.getPeerId(),
|
|
121
|
+
transport.dataPort,
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
} catch {
|
|
125
|
+
// Nothing reachable answered as the port holder; fall through to binding it.
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await this.handleBecomeCoordinator([]);
|
|
129
|
+
return;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (!isAddrInUse(error)) throw error;
|
|
132
|
+
// connectToCoordinator already failed above, so whatever holds this port never answered as a reachable coordinator either. Name the actual mismatch rather than a generic "unavailable".
|
|
133
|
+
this.deps.onError?.(
|
|
134
|
+
new Error(
|
|
135
|
+
`MeshStore: could not join or create a mesh on port ${String(port)}. ` +
|
|
136
|
+
`port ${String(port)} is already in use by something that never answered as a reachable coordinator: a stale process from a previous run, or an incompatible agent-comms version. ` +
|
|
137
|
+
"agent-comms will reach other peers through first contact only until this is resolved. " +
|
|
138
|
+
`(${describe(error)})`,
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
await this.claimWhileAlone();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Claims the elected role for a store that knows of no incumbent and met nobody on the well-known port. With no first-contact presence configured, the port is the only way another store could reach this one, so it claims at once. With one, an incumbent that never held the port may still be about to announce itself over a first-contact session, so the claim waits for it rather than taking the role at an equal term and winning the tiebreak against a holder that was there first. */
|
|
146
|
+
private async claimWhileAlone(): Promise<void> {
|
|
147
|
+
if (this.deps.firstContactPort === undefined) {
|
|
148
|
+
await this.deps.coordinatorRole.claimIfVacant();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.deps.coordinatorRole.claimIfVacantAfterWait();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
handlePeerList(peers: readonly PeerInfo[]): void {
|
|
155
|
+
const peerId = this.deps.getPeerId();
|
|
156
|
+
for (const peer of peers) {
|
|
157
|
+
this.deps.peerInfo.set(peer.id, peer);
|
|
158
|
+
// 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.
|
|
159
|
+
if (peer.id === peerId) continue;
|
|
160
|
+
void this.deps.requireTransport().connectToPeer(peer, peerId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
handlePeerJoined(peer: Readonly<PeerInfo>): void {
|
|
165
|
+
this.deps.peerInfo.set(peer.id, peer);
|
|
166
|
+
const peerId = this.deps.getPeerId();
|
|
167
|
+
if (peer.id === peerId) return;
|
|
168
|
+
void this.deps.requireTransport().connectToPeer(peer, peerId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async handleIntroduction(
|
|
172
|
+
handle: Readonly<ConnectionHandle>,
|
|
173
|
+
msg: Readonly<{ peerId: string; dataPort: number }>,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const newPeer: PeerInfo = {
|
|
176
|
+
id: msg.peerId,
|
|
177
|
+
port: msg.dataPort,
|
|
178
|
+
startedAt: new Date().toISOString(),
|
|
179
|
+
};
|
|
180
|
+
this.deps.peerInfo.set(msg.peerId, newPeer);
|
|
181
|
+
|
|
182
|
+
// Send full peer list to the new peer
|
|
183
|
+
const peerList: MeshMessage = {
|
|
184
|
+
method: "peer_list",
|
|
185
|
+
peers: [...this.deps.peerInfo.values()],
|
|
186
|
+
};
|
|
187
|
+
await this.deps.requireTransport().send(handle, peerList);
|
|
188
|
+
|
|
189
|
+
// Broadcast arrival to all existing peers
|
|
190
|
+
const joined: MeshMessage = { method: "peer_joined", peer: newPeer };
|
|
191
|
+
await this.deps.requireTransport().broadcast(joined);
|
|
192
|
+
|
|
193
|
+
// Connect to the new peer's data server
|
|
194
|
+
void this.deps
|
|
195
|
+
.requireTransport()
|
|
196
|
+
.connectToPeer(newPeer, this.deps.getPeerId());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async handlePeerConnected(
|
|
200
|
+
handle: Readonly<ConnectionHandle>,
|
|
201
|
+
_info: Readonly<PeerInfo>,
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
// If we have state and the peer doesn't, send state sync
|
|
204
|
+
if (this.deps.agents.size > 0) {
|
|
205
|
+
const state: SerialisedState = this.deps.serialise();
|
|
206
|
+
await this.deps.requireTransport().send(handle, {
|
|
207
|
+
method: "state_sync",
|
|
208
|
+
state,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Share this side's known peers over every established connection (agent-comms#341): the receiver's handlePeerList dials each one it does not yet know, and each of those connections shares in turn, so the data-connection graph forms transitively by flooding and needs no coordinator's peer-list handout. Previously only a coordinator's introduce flow ever sent peer_list.
|
|
212
|
+
const peerList: MeshMessage = {
|
|
213
|
+
method: "peer_list",
|
|
214
|
+
peers: [...this.deps.peerInfo.values()],
|
|
215
|
+
};
|
|
216
|
+
await this.deps.requireTransport().send(handle, peerList);
|
|
217
|
+
await this.deps.roomProtocol.flushPendingRoomRequests(handle.id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async handleDataMessage(
|
|
221
|
+
handle: Readonly<ConnectionHandle>,
|
|
222
|
+
msg: MeshMessage,
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
if (msg.method === "state_sync") {
|
|
225
|
+
this.deps.deliveryEngine.applyStateSync(normaliseWireState(msg.state));
|
|
226
|
+
} else if (msg.method === "state_update") {
|
|
227
|
+
await this.deps.deliveryEngine.applyPatch(msg.patch);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Takes over the well-known port listener (a fresh bind in init, a graceful handover, or winning the crash race) and dials the peers named. Holding the port no longer carries the coordinator duties; the one link left is that a port holder which knows of no elected incumbent claims the role, since it is then the first on its machine as far as it can tell. */
|
|
232
|
+
async handleBecomeCoordinator(peerList: readonly PeerInfo[]): Promise<void> {
|
|
233
|
+
// Take over as coordinator using the data server we already have
|
|
234
|
+
await this.deps
|
|
235
|
+
.requireTransport()
|
|
236
|
+
.becomeCoordinator(COORDINATOR_HOST, this.deps.coordinatorPort);
|
|
237
|
+
const peerId = this.deps.getPeerId();
|
|
238
|
+
// peerList never names the successor itself (neither the graceful handover nor the crash race includes it), so this side's own entry is carried across the reset rather than dropped: handleIntroduction answers every later joiner with exactly these entries, and a joiner that is never told the coordinator's own data port never dials it.
|
|
239
|
+
const self = this.deps.peerInfo.get(peerId);
|
|
240
|
+
this.deps.peerInfo.clear();
|
|
241
|
+
if (self !== undefined) this.deps.peerInfo.set(peerId, self);
|
|
242
|
+
for (const peer of peerList) {
|
|
243
|
+
this.deps.peerInfo.set(peer.id, peer);
|
|
244
|
+
void this.deps.requireTransport().connectToPeer(peer, peerId);
|
|
245
|
+
}
|
|
246
|
+
await this.claimWhileAlone();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
handlePeerDisconnected(handle: Readonly<ConnectionHandle>): void {
|
|
250
|
+
void this.handlePeerDeparture(handle.id);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The awaitable form of handlePeerDisconnected, which TransportEvents forces to be synchronous. A departing peer leaves the peer list. When it held the elected coordinator role, the role is recovered first (the lowest surviving device-id claims at a raised term), so the new holder is already in place by the time the departed peer's agent record is retired; when it held the well-known port this side dialled, the vacated port is contested too. */
|
|
254
|
+
async handlePeerDeparture(peerId: string): Promise<void> {
|
|
255
|
+
this.deps.peerInfo.delete(peerId);
|
|
256
|
+
await this.deps.coordinatorRole.handleDeparture(peerId, async () =>
|
|
257
|
+
this.retireDepartedAgent(peerId),
|
|
258
|
+
);
|
|
259
|
+
if (peerId === this.deps.getCoordinatorPeerId()) {
|
|
260
|
+
await this.contestCoordinatorRole(peerId);
|
|
261
|
+
}
|
|
262
|
+
await this.retireDepartedAgent(peerId);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Races every other surviving peer to rebind the well-known port, which is what keeps the compatibility first-contact address answering after its holder CRASHES: unlike the graceful handover, a killed holder names no successor, so each survivor contests independently and the operating system's own exclusive bind decides the single winner. The winner runs the ordinary takeover path (handleBecomeCoordinator) over the peers it still holds connections to; a loser (the one case where the bind fails specifically because the port is already taken) re-introduces itself to the winner so it is a full member of the new mesh again, with its own data port known to whoever joins next. Direct peer-to-peer data connections are untouched throughout, so traffic between survivors never depends on the outcome of this race. */
|
|
266
|
+
private async contestCoordinatorRole(
|
|
267
|
+
lostCoordinatorId: string,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
const transport = this.deps.requireTransport();
|
|
270
|
+
if (transport.isCoordinator || this.contestingCoordinatorRole) return;
|
|
271
|
+
this.contestingCoordinatorRole = true;
|
|
272
|
+
try {
|
|
273
|
+
const selfId = this.deps.getPeerId();
|
|
274
|
+
const survivors = [...this.deps.peerInfo.values()].filter(
|
|
275
|
+
(peer) => peer.id !== selfId && peer.id !== lostCoordinatorId,
|
|
276
|
+
);
|
|
277
|
+
let lastDialFailure: Error | undefined;
|
|
278
|
+
for (let round = 0; round < COORDINATOR_TAKEOVER_ROUNDS; round++) {
|
|
279
|
+
try {
|
|
280
|
+
await this.handleBecomeCoordinator(survivors);
|
|
281
|
+
return;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (!isAddrInUse(error)) {
|
|
284
|
+
this.deps.onError?.(
|
|
285
|
+
new Error(
|
|
286
|
+
`PeerLifecycle: could not take over the vacated coordinator port: ${describe(error)}`,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// A graceful handover racing this crash race can have made this side the coordinator while the bind above was in flight: the bind then fails against this side's own freshly bound listener, and dialling it would be this peer introducing itself to itself. Re-read through requireTransport() rather than the narrowed local, which TypeScript still believes is false from the guard at the top of this method.
|
|
293
|
+
if (this.deps.requireTransport().isCoordinator) return;
|
|
294
|
+
const rejoin = await this.rejoinUnderNewCoordinator(
|
|
295
|
+
selfId,
|
|
296
|
+
lostCoordinatorId,
|
|
297
|
+
);
|
|
298
|
+
if (rejoin.outcome === "joined") return;
|
|
299
|
+
lastDialFailure = rejoin.cause;
|
|
300
|
+
}
|
|
301
|
+
this.deps.onError?.(
|
|
302
|
+
new Error(
|
|
303
|
+
`PeerLifecycle: the vacated coordinator port kept changing hands and this peer neither took it nor found a successor holding it${lastDialFailure === undefined ? "" : `; the last dial failed: ${lastDialFailure.message}`}`,
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
} finally {
|
|
307
|
+
this.contestingCoordinatorRole = false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Re-introduces this side to whichever survivor won the bind race, over a fresh coordinator connection to the same well-known port. Without it this peer would keep its existing data connections but be unknown to the new coordinator, so it would never appear in the peer list handed to the next joiner. Only a dial that reaches a different device than the lost holder counts as joining: the lost holder's listener can still be accepting while its transport closes, and a peer introduced to it would hold a session to a coordinator that is going away while nobody holds the port. Every dial failure is treated as unsettled rather than final for the same reason, since a connection torn down mid-handshake is what a closing listener produces; the caller answers by re-entering the bind race and reports the last failure only once it has run out of rounds. */
|
|
312
|
+
private async rejoinUnderNewCoordinator(
|
|
313
|
+
selfId: string,
|
|
314
|
+
lostCoordinatorId: string,
|
|
315
|
+
): Promise<RejoinOutcome> {
|
|
316
|
+
const transport = this.deps.requireTransport();
|
|
317
|
+
try {
|
|
318
|
+
await transport.connectToCoordinator(
|
|
319
|
+
COORDINATOR_HOST,
|
|
320
|
+
this.deps.coordinatorPort,
|
|
321
|
+
selfId,
|
|
322
|
+
transport.dataPort,
|
|
323
|
+
);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (isConnectionRefused(error)) return { outcome: "unsettled" };
|
|
326
|
+
return {
|
|
327
|
+
outcome: "unsettled",
|
|
328
|
+
cause: error instanceof Error ? error : new Error(describe(error)),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (this.deps.requireTransport().coordinatorPeerId === lostCoordinatorId) {
|
|
332
|
+
return { outcome: "unsettled" };
|
|
333
|
+
}
|
|
334
|
+
return { outcome: "joined" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Marks a departed peer's own agent offline and announces it, but only from the elected coordinator: the same single-authority rule StaleAgentChecker's PID probe already follows, so a departure produces one announcement rather than one per surviving peer. An agent's id is its peer's id (AgentRegistry.registerAgent), so the departed peer names its own record directly; the PID probe remains the backstop for an agent whose process dies without its session closing, and an already-offline record is left alone, so a successor that took over late announces nothing twice. */
|
|
338
|
+
private async retireDepartedAgent(peerId: string): Promise<void> {
|
|
339
|
+
if (!this.deps.coordinatorRole.isHolder()) return;
|
|
340
|
+
const agent = this.deps.agents.get(peerId);
|
|
341
|
+
if (agent === undefined || agent.status === "offline") return;
|
|
342
|
+
// Bumped so that a state snapshot a survivor sends from before it heard of the departure, which still shows the agent running at its old revision, cannot turn it back on.
|
|
343
|
+
agent.status = "offline";
|
|
344
|
+
this.deps.deliveryEngine.bump(agent);
|
|
345
|
+
this.deps.agents.set(peerId, agent);
|
|
346
|
+
await this.deps.deliveryEngine.notifyRoomsOfStatus(peerId, "offline");
|
|
347
|
+
await this.deps.deliveryEngine.broadcastPatch({
|
|
348
|
+
type: "agent_offline",
|
|
349
|
+
agentId: peerId,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Graceful handover of the well-known port listener (agent-comms#170): called by MeshStore.shutdown() while the transport is still up. The elected coordinator role needs no handover, since survivors recover it from the departure itself. A no-op unless this side currently holds the port listener and at least one other peer remains connected. Neither condition is this class's own business to log or report on, since a solo coordinator shutting down or a non-coordinator peer shutting down are both entirely ordinary. When both hold, picks the longest-running remaining peer (the one with the earliest recorded PeerInfo.startedAt, matching the README's documented policy) as the successor and sends it become_coordinator carrying every other remaining peer, exactly the peerList shape handleBecomeCoordinator already expects (it dials each entry itself; the successor doesn't need to be told about itself). The crash-race path (each surviving peer independently racing to rebind the coordinator port) is a separate mechanism and untouched by this method. */
|
|
354
|
+
async sendCoordinatorHandover(): Promise<void> {
|
|
355
|
+
const transport = this.deps.requireTransport();
|
|
356
|
+
if (!transport.isCoordinator) return;
|
|
357
|
+
|
|
358
|
+
const selfId = this.deps.getPeerId();
|
|
359
|
+
const remainingPeers = [...this.deps.peerInfo.values()].filter(
|
|
360
|
+
(peer) => peer.id !== selfId,
|
|
361
|
+
);
|
|
362
|
+
if (remainingPeers.length === 0) return;
|
|
363
|
+
|
|
364
|
+
// filter() above already returned a fresh array, so sorting it in place is safe.
|
|
365
|
+
remainingPeers.sort(
|
|
366
|
+
(a, b) => Date.parse(a.startedAt) - Date.parse(b.startedAt),
|
|
367
|
+
);
|
|
368
|
+
const [successor, ...handoffList] = remainingPeers;
|
|
369
|
+
// remainingPeers.length > 0 (checked above) guarantees at least one entry here -- this narrows the type for TypeScript rather than handling a real runtime case.
|
|
370
|
+
if (successor === undefined) return;
|
|
371
|
+
|
|
372
|
+
await transport.send(
|
|
373
|
+
{ id: successor.id },
|
|
374
|
+
{ method: "become_coordinator", peerList: handoffList },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A failure on the data connection to one specific peer, carrying which peer and what the transport was doing so the error channel's consumers can say something actionable instead of repeating a raw socket message.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** What the transport was doing on the connection to the peer when it failed. */
|
|
6
|
+
export type PeerLinkAction = "dial" | "send";
|
|
7
|
+
|
|
8
|
+
export class PeerLinkError extends Error {
|
|
9
|
+
/**
|
|
10
|
+
* @param action - What the transport was doing when the connection failed.
|
|
11
|
+
* @param peerId - The full device-id of the peer the connection was to.
|
|
12
|
+
* @param port - The data port that was dialled; absent for a failure on an established connection, which no longer names one.
|
|
13
|
+
* @param cause - The transport's own error, kept intact so its errno `code` stays readable.
|
|
14
|
+
*/
|
|
15
|
+
constructor(
|
|
16
|
+
readonly action: PeerLinkAction,
|
|
17
|
+
readonly peerId: string,
|
|
18
|
+
readonly port: number | undefined,
|
|
19
|
+
cause: unknown,
|
|
20
|
+
) {
|
|
21
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
22
|
+
super(
|
|
23
|
+
action === "dial"
|
|
24
|
+
? `connectToPeer(${peerId}, port ${String(port)}): ${reason}`
|
|
25
|
+
: `send(${peerId}) failed: ${reason}`,
|
|
26
|
+
{ cause },
|
|
27
|
+
);
|
|
28
|
+
this.name = "PeerLinkError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The errno code (`ECONNREFUSED`, `ENETUNREACH`, ...) of an error or the first error in its cause chain that has one. */
|
|
33
|
+
export function errnoCode(error: unknown): string | undefined {
|
|
34
|
+
let current: unknown = error;
|
|
35
|
+
while (current instanceof Error) {
|
|
36
|
+
if ("code" in current && typeof current.code === "string")
|
|
37
|
+
return current.code;
|
|
38
|
+
current = current.cause;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peer-versions -- the AGENT_COMMS_VERSION_GOSSIP_KEY gossip extension itself (agent-comms#198's own write side, wired into readvertiseGossip's extensions by gossip-directory.ts), plus reading a specific device's gossiped version facts back out of WireMeshTransport's mesh-wide listKnownDevices() aggregation (the read side), mirroring agent-registry.ts's isAgentSelfAdvert/listDiscoverableAgents and room-lifecycle.ts's isHostedRoomAdvert: the same "narrow an untrusted self-asserted gossip value" pattern, just for wire-mesh-core's own version key and this file's own AGENT_COMMS_VERSION_GOSSIP_KEY.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session";
|
|
6
|
+
import type { MeshTransport } from "./transport.js";
|
|
7
|
+
|
|
8
|
+
/** The domain-qualified gossip extension key this side writes its own agent-comms (and, when this process is fronting/bridging a cc-peer session, cc-peer) package versions under -- the same "one gossip fact per key" convention wire-mesh-transport.ts's own PRESENCE_GOSSIP_KEY/HOSTED_ROOMS_GOSSIP_KEY/AGENT_SELF_GOSSIP_KEY already established. Distinct from wire-mesh-core's own "wire-mesh/version" key (own-version.ts's CORE_VERSION_GOSSIP_KEY, injected automatically by wire-mesh-core itself since wire-mesh#179) -- that one reports wire-mesh-core's own version and needs no writing here at all. */
|
|
9
|
+
export const AGENT_COMMS_VERSION_GOSSIP_KEY = "agent-comms/version";
|
|
10
|
+
|
|
11
|
+
/** The gossip-safe shape this side's own package versions advertise under AGENT_COMMS_VERSION_GOSSIP_KEY. ccPeer is present only while this process is actually fronting a cc-peer session or running the one-shot `bridge cc-peer` command (front-runtime.ts/bridges/cc-peer/run.ts's own getCcPeerVersion) -- absent for every other bridge, which never loads the cc-peer package at all. */
|
|
12
|
+
export interface AgentCommsVersionsAdvert {
|
|
13
|
+
agentComms: string;
|
|
14
|
+
ccPeer?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** wire-mesh-core's own gossip extension key (own-version.ts's CORE_VERSION_GOSSIP_KEY, injected unconditionally into every self-advert since wire-mesh#179) -- not exported as a package subpath, so this side reads it back by its known, spec-documented string (spec/version.cddl) rather than importing the constant. */
|
|
18
|
+
const WIRE_MESH_CORE_VERSION_GOSSIP_KEY = "wire-mesh/version";
|
|
19
|
+
|
|
20
|
+
/** Narrows an untrusted gossiped value (a device's own advert[AGENT_COMMS_VERSION_GOSSIP_KEY], self-asserted by whichever peer advertised it) into an AgentCommsVersionsAdvert -- a malformed or non-conforming entry is silently skipped rather than treated as an error, the same convention every other gossip-extension consumer here already established. */
|
|
21
|
+
function isAgentCommsVersionsAdvert(
|
|
22
|
+
value: unknown,
|
|
23
|
+
): value is AgentCommsVersionsAdvert {
|
|
24
|
+
if (typeof value !== "object" || value === null) return false;
|
|
25
|
+
if (!("agentComms" in value) || typeof value.agentComms !== "string")
|
|
26
|
+
return false;
|
|
27
|
+
if ("ccPeer" in value && typeof value.ccPeer !== "string") return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** This device's own gossiped wire-mesh-core version, if this side has heard it advertise one -- undefined when the device is unknown, its transport has no listKnownDevices capability, or (a wire-mesh-core older than #179) it simply never carried the key. */
|
|
32
|
+
export function getPeerWireMeshCoreVersion(
|
|
33
|
+
transport: Readonly<Pick<MeshTransport, "listKnownDevices">>,
|
|
34
|
+
deviceId: string,
|
|
35
|
+
): string | undefined {
|
|
36
|
+
if (transport.listKnownDevices === undefined) return undefined;
|
|
37
|
+
for (const entry of transport.listKnownDevices()) {
|
|
38
|
+
if (entry.deviceId !== deviceId) continue;
|
|
39
|
+
const version = entry.advert[WIRE_MESH_CORE_VERSION_GOSSIP_KEY];
|
|
40
|
+
return typeof version === "string" ? version : undefined;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** This device's own gossiped agent-comms/cc-peer versions, if this side has heard it advertise any -- undefined under the same conditions getPeerWireMeshCoreVersion documents, or when the device gossiped something that doesn't conform to AgentCommsVersionsAdvert's own shape. */
|
|
46
|
+
export function getPeerAgentCommsVersions(
|
|
47
|
+
transport: Readonly<Pick<MeshTransport, "listKnownDevices">>,
|
|
48
|
+
deviceId: string,
|
|
49
|
+
): AgentCommsVersionsAdvert | undefined {
|
|
50
|
+
if (transport.listKnownDevices === undefined) return undefined;
|
|
51
|
+
for (const entry of transport.listKnownDevices()) {
|
|
52
|
+
if (entry.deviceId !== deviceId) continue;
|
|
53
|
+
const candidate = entry.advert[AGENT_COMMS_VERSION_GOSSIP_KEY];
|
|
54
|
+
return isAgentCommsVersionsAdvert(candidate) ? candidate : undefined;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Asks deviceId over transport for its own, currently-running wire-mesh-core version, live (MeshStore.queryVersion's body, split out to keep mesh-store.ts under the repo's max-lines cap). Resolves to a plain, provider-neutral result rather than wire-mesh-core's own ManageOutcome type. */
|
|
60
|
+
export async function queryPeerVersion(
|
|
61
|
+
transport: Readonly<MeshTransport>,
|
|
62
|
+
deviceId: string,
|
|
63
|
+
): Promise<{ version: string } | { error: string }> {
|
|
64
|
+
if (transport.queryVersion === undefined) {
|
|
65
|
+
return {
|
|
66
|
+
error: "this transport does not support querying a peer's version",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const outcome: ManageOutcome = await transport.queryVersion(deviceId);
|
|
70
|
+
if (outcome.result === "error") {
|
|
71
|
+
return { error: outcome.message ?? outcome.code };
|
|
72
|
+
}
|
|
73
|
+
const version: unknown = outcome.version;
|
|
74
|
+
if (typeof version !== "string") {
|
|
75
|
+
return { error: "peer answered version.get with no version string" };
|
|
76
|
+
}
|
|
77
|
+
return { version };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The four version reads MeshStore exposes (gossiped agent-comms, cc-peer and wire-mesh-core versions, and the live query), each resolving the transport only when called. */
|
|
81
|
+
export function peerVersionReaders(
|
|
82
|
+
requireTransport: () => Readonly<MeshTransport>,
|
|
83
|
+
): {
|
|
84
|
+
agentComms: (deviceId: string) => string | undefined;
|
|
85
|
+
ccPeer: (deviceId: string) => string | undefined;
|
|
86
|
+
wireMeshCore: (deviceId: string) => string | undefined;
|
|
87
|
+
query: (deviceId: string) => Promise<{ version: string } | { error: string }>;
|
|
88
|
+
} {
|
|
89
|
+
return {
|
|
90
|
+
agentComms: (deviceId) =>
|
|
91
|
+
getPeerAgentCommsVersions(requireTransport(), deviceId)?.agentComms,
|
|
92
|
+
ccPeer: (deviceId) =>
|
|
93
|
+
getPeerAgentCommsVersions(requireTransport(), deviceId)?.ccPeer,
|
|
94
|
+
wireMeshCore: (deviceId) =>
|
|
95
|
+
getPeerWireMeshCoreVersion(requireTransport(), deviceId),
|
|
96
|
+
query: async (deviceId) => queryPeerVersion(requireTransport(), deviceId),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A connect_request held open awaiting a human decision, how long it may wait, and what happens when it waits too long: WireMeshTransport's pending-connection bookkeeping, split out of wire-mesh-transport.ts to keep it under the repo's max-lines cap.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
AcceptedMeshSession,
|
|
7
|
+
IncomingManageRequest,
|
|
8
|
+
} from "wire-mesh-core/domain/mesh-session";
|
|
9
|
+
import type { ListenerPolicy } from "./transport.js";
|
|
10
|
+
|
|
11
|
+
/** How long a connect_request may sit awaiting a human decision before this side gives up and rejects it automatically. Generous on purpose -- this bounds a human approval window, not a network timeout: 5 minutes covers a person genuinely being away from the terminal for a few minutes, while still guaranteeing every unanswered request eventually resolves instead of accumulating in pendingConnections indefinitely. */
|
|
12
|
+
const PENDING_CONNECTION_TIMEOUT_MINUTES = 5;
|
|
13
|
+
const SECONDS_PER_MINUTE = 60;
|
|
14
|
+
const MS_PER_SECOND = 1000;
|
|
15
|
+
export const DEFAULT_PENDING_CONNECTION_TIMEOUT_MS =
|
|
16
|
+
PENDING_CONNECTION_TIMEOUT_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
17
|
+
|
|
18
|
+
/** A human decision on a connect_request: "accept" resumes the still-blocked consumeQuarantined loop as a fully trusted session; "reject" (also used when the requester disconnects before a decision is made) unblocks it to close instead. */
|
|
19
|
+
export type ConnectionDecision = "accept" | "reject";
|
|
20
|
+
|
|
21
|
+
export interface PendingConnection {
|
|
22
|
+
respond: IncomingManageRequest["respond"];
|
|
23
|
+
dataPort: number;
|
|
24
|
+
name: string;
|
|
25
|
+
fingerprint: string;
|
|
26
|
+
policy: ListenerPolicy | undefined;
|
|
27
|
+
/** Held so acceptConnection can trackSession it synchronously, before firing onIntroduction -- that event's own handler (mesh-store's handleIntroduction) sends a reply on this same handle immediately, which needs peerSessions already populated. Waiting for consumeQuarantined's own suspended loop to resume and do it would race: resolve() below only wakes that loop on a later microtask tick, after onIntroduction has already fired. */
|
|
28
|
+
session: AcceptedMeshSession;
|
|
29
|
+
/** Settles the Promise consumeQuarantined is blocked on for this connect_request -- the mechanism by which acceptConnection/rejectConnection resume a loop suspended mid-iteration, without ever needing to re-obtain (and so needing to reason about the identity of) a second iterator over the same session's incomingManageRequests. */
|
|
30
|
+
resolve: (decision: ConnectionDecision) => void;
|
|
31
|
+
/** Auto-rejects this request after the configured pending-connection timeout if no human decision arrives first. Cleared by acceptConnection/rejectConnection/watchForDisconnect's own disconnect path, whichever settles the request first -- an entry is only ever removed from pendingConnections once, so this timer firing after another path already resolved it is structurally impossible, not merely guarded against. */
|
|
32
|
+
timeoutHandle: ReturnType<typeof setTimeout>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Auto-rejects a connect_request that has sat unanswered past WireMeshTransport's pendingConnectionTimeoutMs: the same respond-then-resolve shape rejectConnection uses (a real error response, not a silent hang), since unlike watchForDisconnect's own cleanup path the requester's session is still very much alive and waiting to hear back. A no-op if the request was already settled by acceptConnection/rejectConnection/disconnect before this timer fired, since entries are deleted exactly once, by whichever path settles first. */
|
|
36
|
+
export function expirePendingConnection(
|
|
37
|
+
pendingConnections: Map<string, PendingConnection>,
|
|
38
|
+
id: string,
|
|
39
|
+
): void {
|
|
40
|
+
const pending = pendingConnections.get(id);
|
|
41
|
+
if (pending === undefined) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
pendingConnections.delete(id);
|
|
45
|
+
void pending
|
|
46
|
+
.respond({
|
|
47
|
+
result: "error",
|
|
48
|
+
code: "timeout",
|
|
49
|
+
message: "no human decision within the pending-connection timeout",
|
|
50
|
+
})
|
|
51
|
+
.catch(() => undefined);
|
|
52
|
+
pending.resolve("reject");
|
|
53
|
+
}
|