feat(guild-discovery): add serviceEndpoint for backend-to-backend reachability

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.
This commit is contained in:
h z
2026-05-23 22:21:03 +01:00
parent 604f6556fe
commit 3058bccfb6
5 changed files with 58 additions and 0 deletions

View File

@@ -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) {