@agentmesh-p2p/sdk 0.1.0 → 0.2.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/dist/session.js CHANGED
@@ -51,6 +51,17 @@ const DEFAULT_RELAY_CHECK_MS = 30 * 1000;
51
51
  const DEFAULT_ANNOUNCE_MS = 5 * 60 * 1000;
52
52
  /** What close() waits for the member's record to be withdrawn. */
53
53
  const WITHDRAW_TIMEOUT_MS = 2 * 1000;
54
+ /**
55
+ * Bounds a join whose caller passed no signal: each step has a timeout of
56
+ * its own, but a join tries them in turn over every router it is offered.
57
+ */
58
+ export const JOIN_TIMEOUT_MS = 60_000;
59
+ /**
60
+ * Bounds the control plane sync a join starts with. The sync is best effort,
61
+ * so a control plane that does not answer in time leaves the join to go on
62
+ * with the stored credential instead of using up the whole join.
63
+ */
64
+ export const JOIN_SYNC_TIMEOUT_MS = 5_000;
54
65
  /** How long a provider's positive egress verdict is kept; agentmesh-node's labelGateTTL. */
55
66
  const EGRESS_VERDICT_TTL_MS = 5 * 60 * 1000;
56
67
  /**
@@ -220,7 +231,7 @@ export class MeshSession {
220
231
  * the mesh's record TTL. A member with no relayed address announces
221
232
  * nothing: there is nothing to reach it at.
222
233
  */
223
- async announce() {
234
+ async announce(signal) {
224
235
  if (this.#closed || this.#isTaskView) {
225
236
  return;
226
237
  }
@@ -228,7 +239,7 @@ export class MeshSession {
228
239
  if (addrs.length === 0) {
229
240
  return;
230
241
  }
231
- await provide(this.#directoryRouters(), await peerKey(this.peerId), addrs);
242
+ await provide(this.#directoryRouters(), await peerKey(this.peerId), addrs, signal);
232
243
  this.#announced = addrs;
233
244
  }
234
245
  /** Whether peerId is currently one of the routers this member holds. */
@@ -372,14 +383,15 @@ export class MeshSession {
372
383
  /**
373
384
  * Runs the handshake on a router connection once: the connection:open
374
385
  * listener and an explicit admission both call this, and the second waits
375
- * for the first instead of running a second handshake.
386
+ * for the first instead of running a second handshake. The signal applies
387
+ * to a handshake this call starts; one already running keeps its own.
376
388
  */
377
- #authenticateRouter(conn) {
389
+ #authenticateRouter(conn, signal) {
378
390
  const pending = this.#routerHandshakes.get(conn.id);
379
391
  if (pending !== undefined) {
380
392
  return pending;
381
393
  }
382
- const run = authenticateWithPeer(conn, this.mesh.authFrame(), this.mesh.credential.controlPlaneKeys).finally(() => {
394
+ const run = authenticateWithPeer(conn, this.mesh.authFrame(), this.mesh.credential.controlPlaneKeys, signal).finally(() => {
383
395
  this.#routerHandshakes.delete(conn.id);
384
396
  });
385
397
  this.#routerHandshakes.set(conn.id, run);
@@ -496,6 +508,8 @@ export class MeshSession {
496
508
  */
497
509
  async topUpRouters(signal) {
498
510
  const failures = [];
511
+ // A function, not a narrowed value: the signal aborts across the awaits below.
512
+ const aborted = () => signal?.aborted === true;
499
513
  const want = this.#wantRouters();
500
514
  const candidates = this.#candidatesForHolding();
501
515
  const tried = new Set();
@@ -513,22 +527,37 @@ export class MeshSession {
513
527
  }
514
528
  let progress = false;
515
529
  for (const cand of picks) {
530
+ // A router not dialed yet is not tried under a signal that has
531
+ // already aborted: it would fail at once and be shunned for nothing.
532
+ if (aborted()) {
533
+ return failures;
534
+ }
516
535
  tried.add(cand.peerId);
536
+ // Each step has a timeout of its own; the failure names the one the
537
+ // router was in, so a join that hangs says where.
538
+ let step = `dial ${cand.addresses.map(String).join(", ")}`;
517
539
  try {
518
540
  const conn = await this.node.dial(cand.addresses, signal !== undefined ? { signal } : {});
519
- const credential = await this.#authenticateRouter(conn);
541
+ step = "auth handshake";
542
+ const credential = await this.#authenticateRouter(conn, signal);
520
543
  requireRole(credential, ROLE_ROUTER);
521
544
  const addr = connectedAddress(conn, cand.peerId);
522
545
  if (this.#reserveRelay) {
546
+ step = "relay reservation";
523
547
  await this.#reserveOnRouter(cand.peerId, addr);
524
548
  }
525
549
  this.#upsertHeldRouter({ peerId: cand.peerId, addr, credential });
526
550
  progress = true;
527
551
  }
528
552
  catch (err) {
529
- this.#shunRouter(cand.peerId, ROUTER_SHUN_DURATION_MS);
553
+ failures.push(`${cand.peerId}: ${step}: ${errorText(err)}`);
530
554
  await this.node.hangUp(peerIdFromString(cand.peerId)).catch(() => { });
531
- failures.push(`${cand.peerId}: ${err instanceof Error ? err.message : String(err)}`);
555
+ // A router cut off by the signal did not fail on its own, so it is
556
+ // not shunned.
557
+ if (aborted()) {
558
+ return failures;
559
+ }
560
+ this.#shunRouter(cand.peerId, ROUTER_SHUN_DURATION_MS);
532
561
  }
533
562
  }
534
563
  if (!progress && picks.length < need) {
@@ -1187,8 +1216,14 @@ export function selectRouters(candidates, k, options = {}) {
1187
1216
  });
1188
1217
  return scored.slice(0, k).map((s) => s.candidate);
1189
1218
  }
1190
- /** Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p. */
1191
- export async function joinMesh(mesh, options = {}) {
1219
+ /**
1220
+ * Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p.
1221
+ * timeoutMs bounds a join whose caller passed no signal.
1222
+ */
1223
+ export async function joinMesh(mesh, options = {}, timeoutMs = JOIN_TIMEOUT_MS) {
1224
+ const signal = options.signal ?? AbortSignal.timeout(timeoutMs);
1225
+ // Why the join stopped: the caller's reason, or its own timeout.
1226
+ const stopReason = () => (options.signal !== undefined ? errorText(signal.reason) : `timed out after ${timeoutMs}ms`);
1192
1227
  // The pull agentmesh-node makes before it starts: a member resuming from its
1193
1228
  // state directory after a key rotation would otherwise verify the routers,
1194
1229
  // which already hold credentials under the new key, against the keys it
@@ -1196,10 +1231,14 @@ export async function joinMesh(mesh, options = {}) {
1196
1231
  // once joined. Best effort; the stored credential serves when the control
1197
1232
  // plane cannot be reached.
1198
1233
  try {
1199
- await mesh.syncControlPlane();
1234
+ await mesh.syncControlPlane(AbortSignal.any([signal, AbortSignal.timeout(JOIN_SYNC_TIMEOUT_MS)]));
1200
1235
  }
1201
1236
  catch {
1202
- // Joining goes on with what the credential holds.
1237
+ // Joining goes on with what the credential holds, unless the caller
1238
+ // stopped the join while the control plane kept it waiting.
1239
+ if (signal.aborted) {
1240
+ throw new Error(`join stopped while syncing with the control plane: ${stopReason()}`);
1241
+ }
1203
1242
  }
1204
1243
  const rawAddrs = options.routerAddresses ?? mesh.credential.routerAddresses;
1205
1244
  if (rawAddrs.length === 0 && mesh.routers.length === 0) {
@@ -1225,14 +1264,18 @@ export async function joinMesh(mesh, options = {}) {
1225
1264
  onGoAway: (peerId, msg, retryAfterMs) => session?.handleGoAway(peerId, msg, retryAfterMs),
1226
1265
  }), AUTH_HANDLER_OPTIONS);
1227
1266
  session = new MeshSession(mesh, node, [], authenticatedPeers, banned, options);
1228
- const failures = await session.topUpRouters(options.signal);
1229
- if (session.routers.length === 0) {
1267
+ const failures = await session.topUpRouters(signal);
1268
+ // One admitted router is enough to be on the mesh; only the caller's own
1269
+ // signal turns that into a failure.
1270
+ if (options.signal?.aborted === true || session.routers.length === 0) {
1230
1271
  const detail = failures.length > 0 ? `:\n ${failures.join("\n ")}` : "";
1231
- throw new Error(`no router admitted this member${detail}`);
1272
+ throw new Error(signal.aborted ? `join stopped: ${stopReason()}${detail}` : `no router admitted this member${detail}`);
1232
1273
  }
1233
1274
  // Best effort: a router that does not take the record is a warning the
1234
- // next announcement retries; the member is on the mesh regardless.
1235
- await session.announce().catch(() => { });
1275
+ // next announcement retries; the member is on the mesh regardless. Under
1276
+ // the caller's signal only: a join that ran out of its own time still
1277
+ // announces the routers it holds.
1278
+ await session.announce(options.signal).catch(() => { });
1236
1279
  return session;
1237
1280
  }
1238
1281
  catch (err) {
@@ -1245,6 +1288,9 @@ export async function joinMesh(mesh, options = {}) {
1245
1288
  throw err;
1246
1289
  }
1247
1290
  }
1291
+ function errorText(err) {
1292
+ return err instanceof Error ? err.message : String(err);
1293
+ }
1248
1294
  /** The peer a multiaddr ends at, in canonical form: its trailing `/p2p/<id>`, or undefined for a relay address with no target yet. */
1249
1295
  function targetPeerOf(ma) {
1250
1296
  const last = ma.getComponents().at(-1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmesh-p2p/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Native SDK for joining an Agent Mesh from a Node.js agent process or a browser page",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -9,6 +9,9 @@
9
9
  "directory": "sdk/js"
10
10
  },
11
11
  "type": "module",
12
+ "bin": {
13
+ "agentmesh": "./dist/cli.js"
14
+ },
12
15
  "engines": {
13
16
  "node": ">=22.18"
14
17
  },
@@ -72,5 +75,6 @@
72
75
  "esbuild": "^0.28.2",
73
76
  "express": "^5.2.1",
74
77
  "typescript": "^7.0.2"
75
- }
78
+ },
79
+ "mcpName": "io.github.google/agentmesh"
76
80
  }