From 604f6556fed0021d448f9b3592c9875a14627051 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 19:21:49 +0100 Subject: [PATCH] feat(guild-discovery): add purpose column to guild_nodes + cli set-purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a free-form 'purpose' text field on GuildNode so admins can describe what each guild is for (debate broadcasts / agent triage / dev sandbox / etc.). Surfaced through: - GET /api/auth/me/guilds (existing user-facing list) - GET /api/nodes (existing admin-facing list) - new cli: node set-purpose --node-id X --purpose 'text' Admin-only writes via CLI (HangmanLab pattern) — never HTTP. Empty string clears. TypeORM synchronize auto-adds the column on first startup. Motivates: agents discover the right guild by intent (purpose substring match) before picking a channel, so dialectic/announce workflows don't need hardcoded guild ids. --- src/auth/auth.service.ts | 1 + src/cli.ts | 12 ++++++++++++ src/entities/guild-node.entity.ts | 9 +++++++++ src/nodes/node-admin.service.ts | 23 ++++++++++++++++++++++- src/nodes/nodes.controller.ts | 1 + 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5052487..483177e 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -192,6 +192,7 @@ export class AuthService { name: n.name, endpoint: n.endpoint, status: n.status, + purpose: n.purpose ?? null, })); const guildAccessTokens = guilds.map((g) => ({ diff --git a/src/cli.ts b/src/cli.ts index a1b41d8..2bb6b91 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ function printUsageAndExit(): never { console.error(' node dist/cli.js user clear-admin'); 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 config oidc [--issuer ] [--client-id ] [--client-secret ]\n' + ' [--callback-url ] [--post-login-redirect ] [--scopes "openid email profile"]\n' + @@ -91,6 +92,17 @@ async function main() { return; } + if (subject === 'node' && action === 'set-purpose') { + const nodeId = getArg('--node-id'); + const purpose = getArg('--purpose'); + if (!nodeId || purpose === null) printUsageAndExit(); + + const nodes = app.get(NodeAdminService); + const result = await nodes.setPurpose(nodeId, purpose); + 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 92ca27e..46f048b 100644 --- a/src/entities/guild-node.entity.ts +++ b/src/entities/guild-node.entity.ts @@ -20,6 +20,15 @@ export class GuildNode { @Column() endpoint!: string; + // 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 + // right guild by intent ("which guild is about debate broadcasts?") + // without anything hard-coded into agent workflows. admin-only write + // (cli: `node set-purpose --node-id X --purpose Y`). + @Column({ type: 'text', nullable: true }) + purpose!: string | null; + @Column({ type: 'varchar', length: 255, nullable: true }) apiKeyHash!: string | null; diff --git a/src/nodes/node-admin.service.ts b/src/nodes/node-admin.service.ts index 49c9b57..8011f21 100644 --- a/src/nodes/node-admin.service.ts +++ b/src/nodes/node-admin.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import bcrypt from 'bcryptjs'; @@ -55,5 +55,26 @@ export class NodeAdminService { apiKey: rawApiKey, }; } + + // 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) { + const node = await this.nodeRepo.findOne({ where: { nodeId } }); + if (!node) throw new NotFoundException(`node ${nodeId} not found`); + const trimmed = String(purpose ?? '').trim(); + node.purpose = trimmed === '' ? null : trimmed; + const saved = await this.nodeRepo.save(node); + await this.audit.write({ + action: 'node.set_purpose', + targetType: 'node', + targetId: saved.nodeId, + detail: JSON.stringify({ purpose: saved.purpose, via: 'cli' }), + }); + return { + nodeId: saved.nodeId, + name: saved.name, + purpose: saved.purpose, + }; + } } diff --git a/src/nodes/nodes.controller.ts b/src/nodes/nodes.controller.ts index 86b9af6..961300e 100644 --- a/src/nodes/nodes.controller.ts +++ b/src/nodes/nodes.controller.ts @@ -119,6 +119,7 @@ export class NodesController { name: n.name, endpoint: n.endpoint, status: n.status, + purpose: n.purpose ?? null, lastHeartbeatAt: n.lastHeartbeatAt, createdAt: n.createdAt, updatedAt: n.updatedAt,