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:
@@ -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,
|
||||
}));
|
||||
|
||||
12
src/cli.ts
12
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 <id> --name <name> --endpoint <url>');
|
||||
console.error(' node dist/cli.js node set-purpose --node-id <id> --purpose <text>');
|
||||
console.error(' node dist/cli.js node set-service-endpoint --node-id <id> --endpoint <url>');
|
||||
console.error(
|
||||
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
|
||||
' [--callback-url <url>] [--post-login-redirect <url>] [--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<string, unknown> = {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user