feat(guild-discovery): add purpose column to guild_nodes + cli set-purpose

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.
This commit is contained in:
h z
2026-05-23 19:21:49 +01:00
parent ca7c6b408e
commit 604f6556fe
5 changed files with 45 additions and 1 deletions

View File

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

View File

@@ -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 <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 config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
' [--callback-url <url>] [--post-login-redirect <url>] [--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<string, unknown> = {};

View File

@@ -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;

View File

@@ -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,
};
}
}

View File

@@ -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,