From 993ee508ff1c51aac4934b935aea6f0f241894b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:00:25 +0000 Subject: [PATCH] feat: approval reassignment (Part 1) - ncl approvals reassign + reassign_approval MCP tool --- container/agent-runner/src/mcp-tools/index.ts | 1 + .../src/mcp-tools/reassign-approval.ts | 84 ++++++ src/cli/resources/approvals.ts | 29 ++ .../019-approvals-notified-approvers.ts | 15 + src/db/migrations/index.ts | 2 + src/db/sessions.ts | 17 +- src/modules/approvals/index.ts | 51 +++- src/modules/approvals/picks.test.ts | 29 ++ src/modules/approvals/primitive.ts | 133 ++++++++- .../approvals/reassign-approval.test.ts | 256 ++++++++++++++++++ src/types.ts | 2 + 11 files changed, 610 insertions(+), 9 deletions(-) create mode 100644 container/agent-runner/src/mcp-tools/reassign-approval.ts create mode 100644 src/db/migrations/019-approvals-notified-approvers.ts create mode 100644 src/modules/approvals/reassign-approval.test.ts diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index bdaef5cc31a..61a71853d1f 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -10,6 +10,7 @@ import './scheduling.js'; import './interactive.js'; import './agents.js'; import './self-mod.js'; +import './reassign-approval.js'; import { startMcpServer } from './server.js'; function log(msg: string): void { diff --git a/container/agent-runner/src/mcp-tools/reassign-approval.ts b/container/agent-runner/src/mcp-tools/reassign-approval.ts new file mode 100644 index 00000000000..ce4317301d0 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/reassign-approval.ts @@ -0,0 +1,84 @@ +/** + * `reassign_approval` MCP tool. + * + * Lets the agent ask the host to re-send a pending approval card to the + * next available admin (skipping anyone already notified), or to a specific + * admin. Fire-and-forget: the tool writes a system action to messages_out + * and the host processes it via the registered `reassign_approval` delivery + * action. + * + * Use when: + * - The admin who received the original card is unavailable. + * - You want to route the approval to a specific admin. + * - You received a "no response" notification and want to escalate. + */ +import { writeMessageOut } from '../db/messages-out.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +export const reassignApproval: McpToolDefinition = { + tool: { + name: 'reassign_approval', + description: + 'Re-send a pending approval card to the next available admin, or to a specific admin. ' + + 'Use when the original approver is unavailable or unresponsive. Fire-and-forget — ' + + 'you will be notified of the result via a system message.', + inputSchema: { + type: 'object' as const, + properties: { + approval_id: { + type: 'string', + description: 'The approval ID to reassign (from the original approval request).', + }, + to_user_id: { + type: 'string', + description: + 'Optional: specific admin user ID to reassign to (e.g. "telegram:123456"). ' + + 'If omitted, the host auto-picks the next eligible admin who has not yet been notified.', + }, + }, + required: ['approval_id'], + }, + }, + async handler(args) { + const approvalId = args.approval_id as string | undefined; + if (!approvalId) return err('approval_id is required'); + + const toUserId = args.to_user_id as string | undefined; + + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'reassign_approval', + approval_id: approvalId, + ...(toUserId ? { to_user_id: toUserId } : {}), + }), + }); + + log(`reassign_approval: ${requestId} → approvalId=${approvalId}${toUserId ? ` to=${toUserId}` : ''}`); + return ok( + `Reassignment request submitted for approval ${approvalId}. ` + + `You will be notified when the card has been re-delivered${toUserId ? ` to ${toUserId}` : ''}.`, + ); + }, +}; + +registerTools([reassignApproval]); diff --git a/src/cli/resources/approvals.ts b/src/cli/resources/approvals.ts index c67f4bcf901..2c8b45930e3 100644 --- a/src/cli/resources/approvals.ts +++ b/src/cli/resources/approvals.ts @@ -1,4 +1,5 @@ import { registerResource } from '../crud.js'; +import { reassignApproval } from '../../modules/approvals/index.js'; registerResource({ name: 'approval', @@ -48,6 +49,34 @@ registerResource({ }, { name: 'title', type: 'string', description: 'Card title shown to the admin.' }, { name: 'options_json', type: 'json', description: 'Card button options as JSON array.' }, + { + name: 'notified_approver_ids', + type: 'json', + description: 'JSON array of user IDs that have already been sent a card for this approval.', + }, ], operations: { list: 'open', get: 'open' }, + customOperations: { + reassign: { + access: 'open', + description: + 'Re-deliver a pending approval card to the next available admin (skipping already-notified ones), or to a specific admin with --to.', + args: [ + { name: 'id', type: 'string', description: 'Approval ID to reassign.', required: true }, + { + name: 'to', + type: 'string', + description: 'Specific user ID to reassign to (optional; auto-picks next admin if omitted).', + }, + ], + async handler(args) { + const approvalId = args.id as string | undefined; + if (!approvalId) throw new Error('--id is required'); + const toUserId = args.to as string | undefined; + const result = await reassignApproval({ approvalId, toUserId }); + if (!result.ok) throw new Error(result.message); + return { message: result.message }; + }, + }, + }, }); diff --git a/src/db/migrations/019-approvals-notified-approvers.ts b/src/db/migrations/019-approvals-notified-approvers.ts new file mode 100644 index 00000000000..82a5159d0d0 --- /dev/null +++ b/src/db/migrations/019-approvals-notified-approvers.ts @@ -0,0 +1,15 @@ +import type { Migration } from './index.js'; + +/** + * `notified_approver_ids` on `pending_approvals`: JSON array of user IDs that + * have already been sent an approval card for this request. Used by the + * reassign flow to skip already-notified approvers when picking the next one. + * NULL on rows created before this migration; treated as an empty list. + */ +export const migration019: Migration = { + version: 19, + name: 'approvals-notified-approvers', + up(db) { + db.exec(`ALTER TABLE pending_approvals ADD COLUMN notified_approver_ids TEXT;`); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index d8f61938922..80b6c9367fd 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -17,6 +17,7 @@ import { migration016 } from './016-messaging-group-instance.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; import { migration018 } from './018-approvals-approver-user-id.js'; +import { migration019 } from './019-approvals-notified-approvers.js'; export interface Migration { version: number; @@ -41,6 +42,7 @@ export const migrations: Migration[] = [ migration017, moduleApprovalsTitleOptions, migration018, + migration019, migration008, migration009, migration010, diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 060f58645b1..a9585fd8ced 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -155,11 +155,11 @@ export function createPendingApproval( `INSERT OR IGNORE INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, - title, options_json, approver_user_id) + title, options_json, approver_user_id, notified_approver_ids) VALUES (@approval_id, @session_id, @request_id, @action, @payload, @created_at, @agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status, - @title, @options_json, @approver_user_id)`, + @title, @options_json, @approver_user_id, @notified_approver_ids)`, ) .run({ session_id: null, @@ -170,6 +170,7 @@ export function createPendingApproval( expires_at: null, status: 'pending', approver_user_id: null, + notified_approver_ids: null, ...pa, }); return result.changes > 0; @@ -185,6 +186,18 @@ export function updatePendingApprovalStatus(approvalId: string, status: PendingA getDb().prepare('UPDATE pending_approvals SET status = ? WHERE approval_id = ?').run(status, approvalId); } +export function updateApprovalNotifiedApprovers(approvalId: string, notifiedIds: string[]): void { + getDb() + .prepare('UPDATE pending_approvals SET notified_approver_ids = ? WHERE approval_id = ?') + .run(JSON.stringify(notifiedIds), approvalId); +} + +export function updateApprovalApprover(approvalId: string, approverUserId: string, notifiedIds: string[]): void { + getDb() + .prepare('UPDATE pending_approvals SET approver_user_id = ?, notified_approver_ids = ? WHERE approval_id = ?') + .run(approverUserId, JSON.stringify(notifiedIds), approvalId); +} + export function deletePendingApproval(approvalId: string): void { getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId); } diff --git a/src/modules/approvals/index.ts b/src/modules/approvals/index.ts index f70a43f4e22..0e817ea0aa2 100644 --- a/src/modules/approvals/index.ts +++ b/src/modules/approvals/index.ts @@ -11,19 +11,30 @@ * - An adapter-ready callback that starts the OneCLI manual-approval handler * once the delivery adapter is set. * - A shutdown callback that stops the OneCLI handler cleanly. + * - A `reassign_approval` delivery action so agents can request the card + * be re-sent to the next available admin via the `reassign_approval` MCP tool. * * Self-mod flows (install_packages, add_mcp_server) moved out to * `src/modules/self-mod/` in PR #7 — they now register delivery actions * + approval handlers via this module's public API. */ -import { onDeliveryAdapterReady } from '../../delivery.js'; +import { onDeliveryAdapterReady, registerDeliveryAction } from '../../delivery.js'; import { registerResponseHandler, onShutdown } from '../../response-registry.js'; +import { log } from '../../log.js'; +import { getPendingApproval, getSession } from '../../db/sessions.js'; import { handleApprovalsResponse } from './response-handler.js'; import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-approvals.js'; +import { notifyAgent, reassignApproval } from './primitive.js'; // Public API re-exports so consumers import from the module root. -export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js'; -export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } from './primitive.js'; +export { requestApproval, registerApprovalHandler, notifyAgent, reassignApproval } from './primitive.js'; +export type { + ApprovalHandler, + ApprovalHandlerContext, + RequestApprovalOptions, + ReassignApprovalOptions, + ReassignApprovalResult, +} from './primitive.js'; registerResponseHandler(handleApprovalsResponse); @@ -34,3 +45,37 @@ onDeliveryAdapterReady((adapter) => { onShutdown(() => { stopOneCLIApprovalHandler(); }); + +// Delivery action: agent-initiated approval reassignment. +// The container writes { action: 'reassign_approval', approval_id, to_user_id? } +// to messages_out. The host picks it up here and re-delivers the card. +registerDeliveryAction('reassign_approval', async (content, session) => { + const approvalId = content.approval_id as string | undefined; + if (!approvalId) { + notifyAgent(session, 'reassign_approval failed: approval_id is required.'); + return; + } + + // Guard: the approval must belong to this session's agent group. + const approval = getPendingApproval(approvalId); + if (!approval) { + notifyAgent(session, `reassign_approval failed: approval not found: ${approvalId}`); + return; + } + if (approval.session_id) { + const approvalSession = getSession(approval.session_id); + if (!approvalSession || approvalSession.agent_group_id !== session.agent_group_id) { + notifyAgent(session, `reassign_approval failed: approval ${approvalId} does not belong to this agent group.`); + log.warn('reassign_approval: cross-group attempt blocked', { + sessionId: session.id, + approvalId, + approvalAgentGroupId: approvalSession?.agent_group_id, + }); + return; + } + } + + const toUserId = content.to_user_id as string | undefined; + const result = await reassignApproval({ approvalId, toUserId, agentGroupId: session.agent_group_id }); + notifyAgent(session, result.message); +}); diff --git a/src/modules/approvals/picks.test.ts b/src/modules/approvals/picks.test.ts index 0d1784acabd..36e4536007e 100644 --- a/src/modules/approvals/picks.test.ts +++ b/src/modules/approvals/picks.test.ts @@ -143,4 +143,33 @@ describe('pickApprovalDelivery', () => { seedUser('telegram:111', 'telegram'); expect(await pickApprovalDelivery(['telegram:111'], 'telegram')).toBeNull(); }); + + it('skips excluded user IDs', async () => { + await mountMockAdapter('telegram'); + seedUser('telegram:111', 'telegram'); + seedUser('telegram:222', 'telegram'); + + // telegram:111 is already notified — should fall through to telegram:222 + const result = await pickApprovalDelivery(['telegram:111', 'telegram:222'], 'telegram', ['telegram:111']); + expect(result?.userId).toBe('telegram:222'); + }); + + it('returns null when all approvers are excluded', async () => { + await mountMockAdapter('telegram'); + seedUser('telegram:111', 'telegram'); + + const result = await pickApprovalDelivery(['telegram:111'], 'telegram', ['telegram:111']); + expect(result).toBeNull(); + }); + + it('exclude does not affect same-channel tie-break when candidate is not excluded', async () => { + await mountMockAdapter('telegram'); + await mountMockAdapter('discord', async (h) => `dm-${h}`); + seedUser('telegram:111', 'telegram'); + seedUser('discord:222', 'discord'); + + // telegram:111 excluded; discord:222 should still be found despite origin being 'discord' + const result = await pickApprovalDelivery(['telegram:111', 'discord:222'], 'discord', ['telegram:111']); + expect(result?.userId).toBe('discord:222'); + }); }); diff --git a/src/modules/approvals/primitive.ts b/src/modules/approvals/primitive.ts index e27a22d6edd..8eab6b6fd89 100644 --- a/src/modules/approvals/primitive.ts +++ b/src/modules/approvals/primitive.ts @@ -23,13 +23,18 @@ */ import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; import { getMessagingGroup } from '../../db/messaging-groups.js'; -import { createPendingApproval, getSession } from '../../db/sessions.js'; +import { + createPendingApproval, + getSession, + updateApprovalApprover, + updateApprovalNotifiedApprovers, +} from '../../db/sessions.js'; import { getDeliveryAdapter } from '../../delivery.js'; import { wakeContainer } from '../../container-runner.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; import type { MessagingGroup, PendingApproval, Session } from '../../types.js'; -import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js'; +import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners, hasAdminPrivilege } from '../permissions/db/user-roles.js'; import { ensureUserDm } from '../permissions/user-dm.js'; /** Two-button approval UI — the only options the primitive supports today. */ @@ -143,19 +148,25 @@ export function pickApprover(agentGroupId: string | null): string[] { * Tie-break: prefer approvers reachable on the same channel kind as the * origin; else first in list. Resolution uses ensureUserDm, which may * trigger a platform openDM call on cache miss. + * + * @param exclude - User IDs to skip (already notified). Defaults to []. */ export async function pickApprovalDelivery( approvers: string[], originChannelType: string, + exclude: string[] = [], ): Promise<{ userId: string; messagingGroup: MessagingGroup } | null> { + const excludeSet = new Set(exclude); + const candidates = approvers.filter((id) => !excludeSet.has(id)); + if (originChannelType) { - for (const userId of approvers) { + for (const userId of candidates) { if (channelTypeOf(userId) !== originChannelType) continue; const mg = await ensureUserDm(userId); if (mg) return { userId, messagingGroup: mg }; } } - for (const userId of approvers) { + for (const userId of candidates) { const mg = await ensureUserDm(userId); if (mg) return { userId, messagingGroup: mg }; } @@ -238,6 +249,7 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise [--to ]` (CLI path) + * - The `reassign_approval` delivery-action handler (MCP tool path) + */ +export async function reassignApproval(opts: ReassignApprovalOptions): Promise { + const { approvalId, toUserId, agentGroupId } = opts; + + const { getPendingApproval } = await import('../../db/sessions.js'); + const approval = getPendingApproval(approvalId); + if (!approval) { + return { ok: false, message: `Approval not found: ${approvalId}` }; + } + if (approval.status !== 'pending') { + return { ok: false, message: `Approval ${approvalId} is already ${approval.status}` }; + } + + const alreadyNotified: string[] = approval.notified_approver_ids + ? (JSON.parse(approval.notified_approver_ids) as string[]) + : []; + + // Determine target admin + let targetUserId: string; + let messagingGroup: MessagingGroup; + + if (toUserId) { + // Explicit target — validate it's a real admin + const targetAgentGroupId = approval.agent_group_id ?? null; + const isEligible = + hasAdminPrivilege(toUserId, targetAgentGroupId ?? '') || + getGlobalAdmins().some((r) => r.user_id === toUserId) || + getOwners().some((r) => r.user_id === toUserId); + if (!isEligible) { + return { ok: false, message: `User ${toUserId} does not have admin privilege for this approval.` }; + } + const dm = await ensureUserDm(toUserId); + if (!dm) { + return { ok: false, message: `No DM channel found for ${toUserId}.` }; + } + targetUserId = toUserId; + messagingGroup = dm; + } else { + // Auto-pick: prefer same channel kind as origin (if known), skip already notified + const approvers = pickApprover(approval.agent_group_id ?? null); + if (approvers.length === 0) { + return { ok: false, message: 'No owner or admin configured to approve.' }; + } + + const originChannelType = approval.channel_type ?? ''; + const target = await pickApprovalDelivery(approvers, originChannelType, alreadyNotified); + if (!target) { + return { + ok: false, + message: `No reachable admin remaining. Already notified: ${alreadyNotified.join(', ') || '(none)'}`, + }; + } + targetUserId = target.userId; + messagingGroup = target.messagingGroup; + } + + // Deliver the card + const options: RawOption[] = JSON.parse(approval.options_json); + const adapter = getDeliveryAdapter(); + if (!adapter) { + return { ok: false, message: 'No delivery adapter available.' }; + } + + try { + await adapter.deliver( + messagingGroup.channel_type, + messagingGroup.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: approvalId, + title: approval.title, + question: `[Reassigned] ${approval.title}`, + options, + }), + ); + } catch (err) { + log.error('Failed to deliver reassigned approval card', { approvalId, targetUserId, err }); + return { ok: false, message: `Could not deliver approval card to ${targetUserId}.` }; + } + + // Update the row: new approver + extend notified list (deduped) + const updatedNotified = [...new Set([...alreadyNotified, targetUserId])]; + updateApprovalApprover(approvalId, targetUserId, updatedNotified); + + log.info('Approval reassigned', { approvalId, targetUserId, agentGroupId }); + return { ok: true, message: `Approval ${approvalId} reassigned to ${targetUserId}.` }; +} diff --git a/src/modules/approvals/reassign-approval.test.ts b/src/modules/approvals/reassign-approval.test.ts new file mode 100644 index 00000000000..5ed30fdb354 --- /dev/null +++ b/src/modules/approvals/reassign-approval.test.ts @@ -0,0 +1,256 @@ +/** + * Tests for reassignApproval — re-deliver a pending approval card to the + * next available admin, skipping already-notified ones. + */ +import * as fs from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ChannelAdapter, OutboundMessage } from '../../channels/adapter.js'; +import { + initChannelAdapters, + registerChannelAdapter, + teardownChannelAdapters, +} from '../../channels/channel-registry.js'; +import { closeDb, createAgentGroup, initTestDb, runMigrations } from '../../db/index.js'; +import { createSession, createPendingApproval, getPendingApproval } from '../../db/sessions.js'; +import { upsertUser } from '../permissions/db/users.js'; +import { grantRole } from '../permissions/db/user-roles.js'; +import { setDeliveryAdapter } from '../../delivery.js'; + +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-reassign-approval' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-reassign-approval'; + +function now(): string { + return new Date().toISOString(); +} + +async function mountMockAdapter(channelType: string): Promise<{ delivered: OutboundMessage[] }> { + const delivered: OutboundMessage[] = []; + const adapter: ChannelAdapter = { + name: channelType, + channelType, + supportsThreads: false, + async setup() {}, + async teardown() {}, + isConnected() { + return true; + }, + async deliver(_platformId, _threadId, message) { + delivered.push(message); + return `msg-${Date.now()}`; + }, + async setTyping() {}, + }; + registerChannelAdapter(channelType, { factory: () => adapter }); + await initChannelAdapters(() => ({ + conversations: [], + onInbound: () => {}, + onInboundEvent: () => {}, + onMetadata: () => {}, + onAction: () => {}, + })); + + // Also wire a delivery adapter that calls through to the channel adapter + setDeliveryAdapter({ + async deliver(ct, platformId, _threadId, _kind, _content) { + await adapter.deliver(platformId, null, { kind: 'text', text: '' } as never); + return `pm-${Date.now()}`; + }, + }); + + return { delivered }; +} + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + const db = initTestDb(); + runMigrations(db); + + createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() }); + createSession({ + id: 'sess-1', + agent_group_id: 'ag-1', + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: now(), + created_at: now(), + }); +}); + +afterEach(async () => { + await teardownChannelAdapters(); + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe('reassignApproval', () => { + it('returns error for unknown approval id', async () => { + const { reassignApproval } = await import('./primitive.js'); + const result = await reassignApproval({ approvalId: 'no-such-approval' }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/not found/i); + }); + + it('returns error for already-resolved approval', async () => { + const { reassignApproval } = await import('./primitive.js'); + createPendingApproval({ + approval_id: 'appr-done', + session_id: 'sess-1', + request_id: 'appr-done', + action: 'install_packages', + payload: '{}', + created_at: now(), + title: 'Install', + options_json: '[]', + status: 'approved', + }); + + const result = await reassignApproval({ approvalId: 'appr-done' }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/already approved/i); + }); + + it('returns error when no admins are configured', async () => { + const { reassignApproval } = await import('./primitive.js'); + createPendingApproval({ + approval_id: 'appr-1', + session_id: 'sess-1', + request_id: 'appr-1', + action: 'install_packages', + payload: '{}', + created_at: now(), + title: 'Install', + options_json: '[]', + }); + + const result = await reassignApproval({ approvalId: 'appr-1' }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/no owner or admin/i); + }); + + it('auto-picks next admin skipping already-notified, updates notified_approver_ids', async () => { + await mountMockAdapter('telegram'); + upsertUser({ id: 'telegram:111', kind: 'telegram', display_name: null, created_at: now() }); + upsertUser({ id: 'telegram:222', kind: 'telegram', display_name: null, created_at: now() }); + grantRole({ user_id: 'telegram:111', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() }); + grantRole({ user_id: 'telegram:222', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() }); + + createPendingApproval({ + approval_id: 'appr-2', + session_id: 'sess-1', + agent_group_id: 'ag-1', + request_id: 'appr-2', + action: 'install_packages', + payload: '{}', + created_at: now(), + title: 'Install', + options_json: JSON.stringify([ + { label: 'Approve', value: 'approve' }, + { label: 'Reject', value: 'reject' }, + ]), + notified_approver_ids: JSON.stringify(['telegram:111']), + }); + + const { reassignApproval } = await import('./primitive.js'); + const result = await reassignApproval({ approvalId: 'appr-2' }); + expect(result.ok).toBe(true); + expect(result.message).toContain('telegram:222'); + + // notified list should now include both + const updated = getPendingApproval('appr-2'); + const notified = JSON.parse(updated!.notified_approver_ids!); + expect(notified).toContain('telegram:111'); + expect(notified).toContain('telegram:222'); + }); + + it('returns error when all admins have already been notified', async () => { + await mountMockAdapter('telegram'); + upsertUser({ id: 'telegram:111', kind: 'telegram', display_name: null, created_at: now() }); + grantRole({ user_id: 'telegram:111', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() }); + + createPendingApproval({ + approval_id: 'appr-3', + session_id: 'sess-1', + agent_group_id: 'ag-1', + request_id: 'appr-3', + action: 'install_packages', + payload: '{}', + created_at: now(), + title: 'Install', + options_json: '[]', + notified_approver_ids: JSON.stringify(['telegram:111']), + }); + + const { reassignApproval } = await import('./primitive.js'); + const result = await reassignApproval({ approvalId: 'appr-3' }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/no reachable admin remaining/i); + }); + + it('reassigns to explicit toUserId when provided', async () => { + await mountMockAdapter('telegram'); + upsertUser({ id: 'telegram:111', kind: 'telegram', display_name: null, created_at: now() }); + upsertUser({ id: 'telegram:333', kind: 'telegram', display_name: null, created_at: now() }); + grantRole({ user_id: 'telegram:111', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() }); + grantRole({ user_id: 'telegram:333', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() }); + + createPendingApproval({ + approval_id: 'appr-4', + session_id: 'sess-1', + agent_group_id: 'ag-1', + request_id: 'appr-4', + action: 'install_packages', + payload: '{}', + created_at: now(), + title: 'Install', + options_json: JSON.stringify([ + { label: 'Approve', value: 'approve' }, + { label: 'Reject', value: 'reject' }, + ]), + notified_approver_ids: JSON.stringify(['telegram:111']), + }); + + const { reassignApproval } = await import('./primitive.js'); + const result = await reassignApproval({ approvalId: 'appr-4', toUserId: 'telegram:333' }); + expect(result.ok).toBe(true); + expect(result.message).toContain('telegram:333'); + + const updated = getPendingApproval('appr-4'); + expect(updated?.approver_user_id).toBe('telegram:333'); + }); + + it('returns error when explicit toUserId is not an admin', async () => { + upsertUser({ id: 'telegram:stranger', kind: 'telegram', display_name: null, created_at: now() }); + upsertUser({ id: 'telegram:admin', kind: 'telegram', display_name: null, created_at: now() }); + grantRole({ user_id: 'telegram:admin', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() }); + + createPendingApproval({ + approval_id: 'appr-5', + session_id: 'sess-1', + agent_group_id: 'ag-1', + request_id: 'appr-5', + action: 'install_packages', + payload: '{}', + created_at: now(), + title: 'Install', + options_json: '[]', + }); + + const { reassignApproval } = await import('./primitive.js'); + const result = await reassignApproval({ approvalId: 'appr-5', toUserId: 'telegram:stranger' }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/does not have admin privilege/i); + }); +}); diff --git a/src/types.ts b/src/types.ts index b5c5bdc3cf4..7263ce46459 100644 --- a/src/types.ts +++ b/src/types.ts @@ -206,6 +206,8 @@ export interface PendingApproval { options_json: string; /** When set, only this exact user may resolve the approval. */ approver_user_id: string | null; + /** JSON array of user IDs that have already been sent a card for this approval. */ + notified_approver_ids: string | null; } // ── Agent destinations (central DB) ──