diff --git a/cli/src/client.ts b/cli/src/client.ts index 9230d61..cbd2de4 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -40,7 +40,7 @@ export class AdmpClient { timeoutOverrideMs?: number ): Promise { const url = new URL(path, this.config.base_url); - const host = url.hostname; + const host = url.host; // includes port for non-standard ports (e.g. localhost:8099) // Only set Content-Type when there is a body; avoids spurious header on GETs. const headers: Record = {}; diff --git a/cli/src/commands/round-tables.ts b/cli/src/commands/round-tables.ts new file mode 100644 index 0000000..8fe3170 --- /dev/null +++ b/cli/src/commands/round-tables.ts @@ -0,0 +1,382 @@ +import { Command } from 'commander'; +import { spawn } from 'child_process'; +import { AdmpClient, AdmpError } from '../client.js'; +import { requireConfig } from '../config.js'; +import { success, error, warn, isJsonMode } from '../output.js'; + +// ---- Types ------------------------------------------------------------------ + +interface RoundTableEntry { + id: string; + from: string; + message: string; + timestamp: string; +} + +interface RoundTable { + id: string; + topic: string; + goal: string; + facilitator: string; + participants: string[]; + group_id: string; + status: 'open' | 'resolved' | 'expired'; + thread: RoundTableEntry[]; + outcome: string | null; + created_at: string; + expires_at: string; + resolved_at?: string; + decision?: string; +} + +interface RoundTableListResponse { + round_tables: RoundTable[]; + count: number; +} + +interface CreateRoundTableResponse extends RoundTable { + excluded_participants?: string[]; +} + +// ---- Helpers ---------------------------------------------------------------- + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function validateRtId(id: string): void { + if (!/^[\w\-]+$/.test(id)) { + error('Round table ID must contain only alphanumeric characters, hyphens, and underscores', 'INVALID_ARGUMENT'); + process.exit(1); + } +} + +function printEntry(entry: RoundTableEntry): void { + if (isJsonMode()) { + console.log(JSON.stringify({ event: 'entry', entry })); + return; + } + const ts = new Date(entry.timestamp).toLocaleTimeString(); + console.log(`[${ts}] \x1b[36m${entry.from}\x1b[0m`); + console.log(` ${entry.message}`); + console.log(''); +} + +function printClosure(rt: RoundTable): void { + if (isJsonMode()) { + console.log(JSON.stringify({ event: 'closed', status: rt.status, outcome: rt.outcome, decision: rt.decision })); + return; + } + console.log(''); + console.log(rt.status === 'resolved' ? '\x1b[32m✓ Round table resolved\x1b[0m' : '\x1b[33m⚠ Round table expired\x1b[0m'); + if (rt.outcome) console.log(` Outcome: ${rt.outcome}`); + if (rt.decision) console.log(` Decision: ${rt.decision}`); + console.log(''); +} + +function fireHook(cmd: string, entry: RoundTableEntry): void { + const child = spawn('/bin/sh', ['-c', cmd], { + stdio: ['pipe', 'inherit', 'inherit'], + }); + if (child.stdin) { + child.stdin.on('error', () => { /* ignore EPIPE — hook exited before reading stdin */ }); + child.stdin.write(JSON.stringify(entry) + '\n'); + child.stdin.end(); + } + child.on('error', (err) => warn(`--on-speak hook error: ${err.message}`)); + child.on('exit', (code) => { + if (code !== 0 && code !== null) warn(`--on-speak hook exited with code ${code}`); + }); +} + +// ---- Command registration --------------------------------------------------- + +export function register(program: Command): void { + const cmd = program + .command('round-tables') + .description('Manage Round Table deliberation sessions'); + + // ---- create ---------------------------------------------------------------- + + cmd + .command('create') + .description('Create a new Round Table session') + .requiredOption('--topic ', 'Session topic (max 500 chars)') + .requiredOption('--goal ', 'Desired outcome (max 500 chars)') + .requiredOption('--participants ', 'Comma-separated participant agent IDs') + .option('--timeout-minutes ', 'Auto-expire after N minutes (integer, 1–10080, default 30)') + .addHelpText('after', ` +Examples: + admp round-tables create --topic "API design" --goal "Agree on schema" --participants agent-a,agent-b + admp round-tables create --topic "Incident review" --goal "Root cause" --participants agent-a --timeout-minutes 60`) + .action(async (opts: { topic: string; goal: string; participants: string; timeoutMinutes?: string }) => { + const participants = opts.participants.split(',').map(s => s.trim()).filter(Boolean); + if (participants.length === 0) { + error('--participants must contain at least one agent ID', 'INVALID_ARGUMENT'); + process.exit(1); + } + + let timeoutMinutes: number | undefined; + if (opts.timeoutMinutes !== undefined) { + timeoutMinutes = parseInt(opts.timeoutMinutes, 10); + if (isNaN(timeoutMinutes) || !Number.isInteger(timeoutMinutes) || timeoutMinutes < 1 || timeoutMinutes > 10080) { + error('--timeout-minutes must be an integer between 1 and 10080', 'INVALID_ARGUMENT'); + process.exit(1); + } + } + + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const body: Record = { topic: opts.topic, goal: opts.goal, participants }; + if (timeoutMinutes !== undefined) body.timeout_minutes = timeoutMinutes; + + const res = await client.request('POST', '/api/round-tables', body, 'signature'); + + if (!isJsonMode() && res.excluded_participants && res.excluded_participants.length > 0) { + warn(`Some participants could not be enrolled: ${res.excluded_participants.join(', ')}`); + } + success('Round table created', res); + }); + + // ---- list ------------------------------------------------------------------ + + cmd + .command('list') + .description('List Round Tables you are a facilitator or participant of') + .option('--status ', 'Filter by status: open | resolved | expired') + .addHelpText('after', ` +Examples: + admp round-tables list + admp round-tables list --status open`) + .action(async (opts: { status?: string }) => { + const VALID_STATUSES = ['open', 'resolved', 'expired']; + if (opts.status && !VALID_STATUSES.includes(opts.status)) { + error(`--status must be one of: ${VALID_STATUSES.join(', ')}`, 'INVALID_ARGUMENT'); + process.exit(1); + } + + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const params = opts.status ? `?status=${encodeURIComponent(opts.status)}` : ''; + const res = await client.request('GET', `/api/round-tables${params}`, undefined, 'signature'); + + const tables = res?.round_tables ?? []; + if (isJsonMode()) { console.log(JSON.stringify(tables, null, 2)); return; } + + if (tables.length === 0) { console.log('No round tables.'); return; } + + const idWidth = Math.max('ID'.length, ...tables.map(t => t.id.length)); + console.log(`\n${'ID'.padEnd(idWidth)} ${'TOPIC'.padEnd(42)} ${'STATUS'.padEnd(10)} ${'PARTS'.padEnd(5)} EXPIRES`); + console.log('─'.repeat(idWidth + 1 + 42 + 1 + 10 + 1 + 5 + 1 + 24)); + for (const t of tables) { + const topic = t.topic.length > 40 ? t.topic.slice(0, 39) + '…' : t.topic; + console.log(`${t.id.padEnd(idWidth)} ${topic.padEnd(42)} ${t.status.padEnd(10)} ${String(t.participants.length).padEnd(5)} ${t.expires_at}`); + } + console.log(''); + }); + + // ---- get ------------------------------------------------------------------- + + cmd + .command('get ') + .description('Get a Round Table session and its full thread') + .addHelpText('after', '\nExample:\n admp round-tables get rt_abc123def456') + .action(async (id: string) => { + validateRtId(id); + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const rt = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); + + if (isJsonMode()) { console.log(JSON.stringify(rt, null, 2)); return; } + + console.log(''); + console.log(`\x1b[1m${rt.topic}\x1b[0m \x1b[2m${rt.id}\x1b[0m`); + console.log(` Goal: ${rt.goal}`); + console.log(` Facilitator: ${rt.facilitator}`); + console.log(` Participants: ${rt.participants.join(', ') || '(none)'}`); + console.log(` Status: ${rt.status}`); + console.log(` Expires: ${rt.expires_at}`); + if (rt.outcome) console.log(` Outcome: ${rt.outcome}`); + if (rt.decision) console.log(` Decision: ${rt.decision}`); + console.log(''); + + if (rt.thread.length === 0) { + console.log(' (no messages yet)'); + } else { + console.log('\x1b[2m─'.repeat(60) + '\x1b[0m'); + for (const entry of rt.thread) { + printEntry(entry); + } + } + }); + + // ---- speak ----------------------------------------------------------------- + + cmd + .command('speak ') + .description('Add a message to the Round Table thread') + .requiredOption('--message ', 'Message to contribute (max 10000 chars)') + .addHelpText('after', '\nExample:\n admp round-tables speak rt_abc123def456 --message "I propose we use event sourcing."') + .action(async (id: string, opts: { message: string }) => { + validateRtId(id); + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const res = await client.request<{ thread_entry_id: string; thread_length: number }>( + 'POST', + `/api/round-tables/${id}/speak`, + { message: opts.message }, + 'signature' + ); + + success('Message posted', res); + }); + + // ---- resolve --------------------------------------------------------------- + + cmd + .command('resolve ') + .description('Close a Round Table with an outcome (facilitator only)') + .requiredOption('--outcome ', 'Summary of what was decided (max 2000 chars)') + .option('--decision ', 'Structured decision string (defaults to "approved")') + .addHelpText('after', ` +Examples: + admp round-tables resolve rt_abc123def456 --outcome "We will use event sourcing." + admp round-tables resolve rt_abc123def456 --outcome "Rejected." --decision rejected`) + .action(async (id: string, opts: { outcome: string; decision?: string }) => { + validateRtId(id); + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const body: Record = { outcome: opts.outcome }; + if (opts.decision !== undefined) body.decision = opts.decision; + + const res = await client.request('POST', `/api/round-tables/${id}/resolve`, body, 'signature'); + success('Round table resolved', res); + }); + + // ---- watch (daemon loop) --------------------------------------------------- + + cmd + .command('watch ') + .description('Watch a Round Table for new thread entries (daemon loop)') + .option('--interval ', 'Poll interval in milliseconds (min 500, default 3000)', '3000') + .option('--on-speak ', 'Shell command to run on each new entry (entry JSON piped to stdin)') + .option('--no-exit-on-close', 'Do not auto-exit when session resolves or expires') + .addHelpText('after', ` +Examples: + admp round-tables watch rt_abc123def456 + admp round-tables watch rt_abc123def456 --interval 5000 + admp round-tables watch rt_abc123def456 --on-speak 'jq .message' + admp round-tables watch rt_abc123def456 --json --on-speak 'my-agent-handler' + admp round-tables watch rt_abc123def456 --no-exit-on-close`) + .action(async (id: string, opts: { interval: string; onSpeak?: string; exitOnClose: boolean }) => { + validateRtId(id); + + const intervalMs = (() => { + const n = parseInt(opts.interval, 10); + if (isNaN(n) || n < 500) { + error('--interval must be at least 500 ms', 'INVALID_ARGUMENT'); + process.exit(1); + } + return n; + })(); + + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + // Initial fetch — print banner and initialise cursor so we don't replay history + const rt = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); + + const initialLength = rt.thread.length; // entries that existed before this watch started + let lastLength = initialLength; + + if (isJsonMode()) { + console.log(JSON.stringify({ event: 'watch_start', round_table: rt })); + } else { + console.log(''); + console.log(`\x1b[1mWatching Round Table\x1b[0m \x1b[2m${rt.id}\x1b[0m`); + console.log(` Topic: ${rt.topic}`); + console.log(` Goal: ${rt.goal}`); + console.log(` Expires: ${rt.expires_at}`); + console.log(` Participants: ${rt.participants.length} (${rt.participants.join(', ')})`); + console.log(` Status: ${rt.status}`); + if (lastLength > 0) console.log(` Thread: ${lastLength} existing entries (skipped)`); + console.log(`Polling every ${intervalMs}ms — Ctrl+C to exit`); + console.log('\x1b[2m' + '─'.repeat(60) + '\x1b[0m'); + console.log(''); + } + + if (opts.exitOnClose && rt.status !== 'open') { + printClosure(rt); + process.exit(rt.status === 'resolved' ? 0 : 1); + } + + let running = true; + + // Signal handlers — report only entries seen during this watch, not pre-existing ones + const handleSignal = (signal: string) => { + const entriesSeen = lastLength - initialLength; + if (isJsonMode()) { + console.log(JSON.stringify({ event: 'interrupted', signal, entries_seen: entriesSeen })); + } else { + console.log(`\nReceived ${signal} — stopping watch.`); + console.log(` Entries seen: ${entriesSeen}`); + } + process.exit(0); + }; + process.once('SIGTERM', () => handleSignal('SIGTERM')); + process.once('SIGINT', () => handleSignal('SIGINT')); + + // Poll loop + const MAX_CONSECUTIVE_ERRORS = 10; + let consecutiveErrors = 0; + + while (running) { + await sleep(intervalMs); + if (!running) break; + + let current: RoundTable; + try { + current = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); + consecutiveErrors = 0; // reset on success + } catch (err) { + if (err instanceof AdmpError && err.status < 500) { + // Fatal client error (403 removed from session, 404 session deleted, etc.) — stop watching + error(`Watch terminated: ${err.message}`, err.code); + process.exit(1); + } + consecutiveErrors++; + const msg = err instanceof AdmpError ? err.message : String(err); + warn(`Poll error (will retry): ${msg}`); + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + error(`Watch terminated: ${MAX_CONSECUTIVE_ERRORS} consecutive poll errors`, 'POLL_FAILED'); + process.exit(1); + } + continue; + } + + // Emit new entries + const newEntries = current.thread.slice(lastLength); + for (const entry of newEntries) { + printEntry(entry); + if (opts.onSpeak) fireHook(opts.onSpeak, entry); + } + lastLength = current.thread.length; + + // Warn if thread is at server-side cap — no new entries will appear until session closes + if (lastLength >= 200 && newEntries.length === 0 && current.status === 'open') { + warn('Thread is at the 200-entry cap — no new messages can be added until the session resolves or expires'); + } + + // Auto-exit when session closes + if (opts.exitOnClose && current.status !== 'open') { + printClosure(current); + process.exit(current.status === 'resolved' ? 0 : 1); + } + } + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 43a328f..68bee8a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -20,6 +20,7 @@ import * as rotateKeyCmd from './commands/rotate-key.js'; import * as webhookCmd from './commands/webhook.js'; import * as groupsCmd from './commands/groups.js'; import * as outboxCmd from './commands/outbox.js'; +import * as roundTablesCmd from './commands/round-tables.js'; export const program = new Command(); @@ -33,7 +34,7 @@ const commandModules = [ initCmd, configCmd, registerCmd, agentCmd, sendCmd, pullCmd, ackCmd, nackCmd, replyCmd, statusCmd, inboxCmd, heartbeatCmd, rotateKeyCmd, - webhookCmd, groupsCmd, outboxCmd, + webhookCmd, groupsCmd, outboxCmd, roundTablesCmd, ]; for (const mod of commandModules) { diff --git a/src/routes/round-tables.js b/src/routes/round-tables.js index 41b5e93..3736065 100644 --- a/src/routes/round-tables.js +++ b/src/routes/round-tables.js @@ -5,7 +5,7 @@ import express from 'express'; import { roundTableService } from '../services/round-table.service.js'; -import { authenticateAgent } from '../middleware/auth.js'; +import { authenticateHttpSignature } from '../middleware/auth.js'; const router = express.Router(); @@ -17,7 +17,7 @@ function getErrorStatusCode(error) { * POST /api/round-tables * Create a new Round Table session */ -router.post('/', authenticateAgent, async (req, res) => { +router.post('/', authenticateHttpSignature, async (req, res) => { try { const { topic, goal, participants, timeout_minutes } = req.body; @@ -65,7 +65,7 @@ router.post('/', authenticateAgent, async (req, res) => { * GET /api/round-tables * List Round Tables, optionally filtered by status and/or participant */ -router.get('/', authenticateAgent, async (req, res) => { +router.get('/', authenticateHttpSignature, async (req, res) => { try { const { status } = req.query; const tables = await roundTableService.list({ @@ -82,7 +82,7 @@ router.get('/', authenticateAgent, async (req, res) => { * GET /api/round-tables/:id * Get a Round Table session (facilitator or participant only) */ -router.get('/:id', authenticateAgent, async (req, res) => { +router.get('/:id', authenticateHttpSignature, async (req, res) => { try { const rt = await roundTableService.get(req.params.id, req.agent.agent_id); res.json(rt); @@ -95,7 +95,7 @@ router.get('/:id', authenticateAgent, async (req, res) => { * POST /api/round-tables/:id/speak * Contribute a message to the thread (participants only) */ -router.post('/:id/speak', authenticateAgent, async (req, res) => { +router.post('/:id/speak', authenticateHttpSignature, async (req, res) => { try { const { message } = req.body; @@ -121,7 +121,7 @@ router.post('/:id/speak', authenticateAgent, async (req, res) => { * POST /api/round-tables/:id/resolve * Resolve a Round Table (facilitator only) */ -router.post('/:id/resolve', authenticateAgent, async (req, res) => { +router.post('/:id/resolve', authenticateHttpSignature, async (req, res) => { try { const { outcome, decision } = req.body;