From 3058bccfb6158c4210df8d4d399baa5075d82ecc Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 22:21:03 +0100 Subject: [PATCH] feat(guild-discovery): add serviceEndpoint for backend-to-backend reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guild's existing 'endpoint' field is its client-facing URL (browser, remote openclaw plugin) — but in-deployment services on the same docker network can't always reach it. Concretely, dialectic-backend on compose_default network sees 'http://server.t3:7002' (from agent-supplied URL) resolve to dind-network IP which it can't route to. Broadcasts silently fail with 'connection refused'. Adds a second URL per guild: serviceEndpoint. Used by other backends in the same deployment (compose service name + internal port). Plumbed through: - GuildNode.serviceEndpoint (varchar 255 nullable; TypeORM auto-migrates) - GET /api/auth/me/guilds returns serviceEndpoint per row - GET /api/nodes (admin) returns serviceEndpoint - new cli: 'node set-service-endpoint --node-id X --endpoint URL' (admin-only via cli — same pattern as set-purpose) Pairs with Fabric.OpenclawPlugin's fabric-guild-list returning the new field + workflows teaching agents to use serviceEndpoint for announce_guild_base_url (NOT the client-facing endpoint). E2e verified: sim recruiter agent discovered sim-guild-1's serviceEndpoint=http://fabric-backend-guild:7002, plumbed it into dialectic_propose_topic, all 4 lifecycle broadcasts (signup_open, signup_closed, debating, completed) landed in the announce channel. --- src/auth/auth.service.ts | 1 + src/cli.ts | 12 ++++++++++++ src/entities/guild-node.entity.ts | 21 +++++++++++++++++++++ src/nodes/node-admin.service.ts | 23 +++++++++++++++++++++++ src/nodes/nodes.controller.ts | 1 + 5 files changed, 58 insertions(+) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 483177e..5a20119 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -191,6 +191,7 @@ export class AuthService { nodeId: n.nodeId, name: n.name, endpoint: n.endpoint, + serviceEndpoint: n.serviceEndpoint ?? null, status: n.status, purpose: n.purpose ?? null, })); diff --git a/src/cli.ts b/src/cli.ts index 2bb6b91..6c5fd90 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ function printUsageAndExit(): never { console.error(' node dist/cli.js user show-admin'); console.error(' node dist/cli.js node register --node-id --name --endpoint '); console.error(' node dist/cli.js node set-purpose --node-id --purpose '); + console.error(' node dist/cli.js node set-service-endpoint --node-id --endpoint '); console.error( ' node dist/cli.js config oidc [--issuer ] [--client-id ] [--client-secret ]\n' + ' [--callback-url ] [--post-login-redirect ] [--scopes "openid email profile"]\n' + @@ -103,6 +104,17 @@ async function main() { return; } + if (subject === 'node' && action === 'set-service-endpoint') { + const nodeId = getArg('--node-id'); + const endpoint = getArg('--endpoint'); + if (!nodeId || endpoint === null) printUsageAndExit(); + + const nodes = app.get(NodeAdminService); + const result = await nodes.setServiceEndpoint(nodeId, endpoint); + process.stdout.write(JSON.stringify({ ok: true, node: result }) + '\n'); + return; + } + if (subject === 'config' && action === 'oidc') { const oidc = app.get(OidcService); const patch: Record = {}; diff --git a/src/entities/guild-node.entity.ts b/src/entities/guild-node.entity.ts index 46f048b..1db35f6 100644 --- a/src/entities/guild-node.entity.ts +++ b/src/entities/guild-node.entity.ts @@ -20,6 +20,27 @@ export class GuildNode { @Column() endpoint!: string; + // Service-to-service endpoint: how *another service inside the same + // deployment* reaches this guild. Distinct from `endpoint` because + // that one is the client-facing URL (browser, remote openclaw plugin, + // anything outside compose), which lives on a different network than + // services co-located with the guild backend. + // + // Concrete examples: + // - sim sim-guild-1: endpoint=http://server.t3:7002 (dind /etc/hosts alias), + // serviceEndpoint=http://fabric-backend-guild:7002 (compose service name) + // - prod t3-node: endpoint=https://fabric-api.hangman-lab.top (public CF), + // serviceEndpoint=http://backend-guild:7002 (compose service name) + // + // Used by dialectic-backend (and any other in-deployment service) to + // post broadcasts to a guild's announce channel. Plugin's + // fabric-guild-list returns this so agents can plumb the right URL + // into announce_guild_base_url on dialectic_propose_topic. + // Null when the admin hasn't filled it in (broadcasts will fail until + // it's set; cli: `node set-service-endpoint --node-id X --endpoint URL`). + @Column({ type: 'varchar', length: 255, nullable: true }) + serviceEndpoint!: string | null; + // Free-form description of this guild's purpose — what it's used for, // who its agents/users serve. Surfaced to agents via /auth/me/guilds so // the cross-guild/cross-channel announce-target picker can find the diff --git a/src/nodes/node-admin.service.ts b/src/nodes/node-admin.service.ts index 8011f21..5cad964 100644 --- a/src/nodes/node-admin.service.ts +++ b/src/nodes/node-admin.service.ts @@ -56,6 +56,29 @@ export class NodeAdminService { }; } + // Admin-only via cli: set the service-to-service endpoint for a guild + // node. This is the URL that another *service in the same deployment* + // (e.g. dialectic-backend on compose) uses to reach the guild — NOT + // the public client-facing URL. Pass empty string to clear. + async setServiceEndpoint(nodeId: string, serviceEndpoint: string) { + const node = await this.nodeRepo.findOne({ where: { nodeId } }); + if (!node) throw new NotFoundException(`node ${nodeId} not found`); + const trimmed = String(serviceEndpoint ?? '').trim(); + node.serviceEndpoint = trimmed === '' ? null : trimmed; + const saved = await this.nodeRepo.save(node); + await this.audit.write({ + action: 'node.set_service_endpoint', + targetType: 'node', + targetId: saved.nodeId, + detail: JSON.stringify({ serviceEndpoint: saved.serviceEndpoint, via: 'cli' }), + }); + return { + nodeId: saved.nodeId, + name: saved.name, + serviceEndpoint: saved.serviceEndpoint, + }; + } + // Admin-only via cli (never HTTP): set the free-form purpose string on // a guild node. Pass an empty string to clear it (null). async setPurpose(nodeId: string, purpose: string) { diff --git a/src/nodes/nodes.controller.ts b/src/nodes/nodes.controller.ts index 961300e..3684d18 100644 --- a/src/nodes/nodes.controller.ts +++ b/src/nodes/nodes.controller.ts @@ -118,6 +118,7 @@ export class NodesController { nodeId: n.nodeId, name: n.name, endpoint: n.endpoint, + serviceEndpoint: n.serviceEndpoint ?? null, status: n.status, purpose: n.purpose ?? null, lastHeartbeatAt: n.lastHeartbeatAt,