diff --git a/src/app/api/agents/mail/ack/route.ts b/src/app/api/agents/mail/ack/route.ts new file mode 100644 index 0000000..bcd3bea --- /dev/null +++ b/src/app/api/agents/mail/ack/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { ackAgentMessage } from '../../../../../lib/agent-mail'; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON.' } }, + { status: 400 }, + ); + } + + const parsed = body as { agent?: string; message?: string }; + const result = await ackAgentMessage({ + agent: parsed.agent ?? '', + message: parsed.message ?? '', + }); + + if (!result.ok) { + const status = result.error?.code === 'ACK_FORBIDDEN' ? 403 : result.error?.code === 'AGENT_NOT_FOUND' || result.error?.code === 'MESSAGE_NOT_FOUND' ? 404 : 400; + return NextResponse.json( + { ok: false, error: { code: result.error?.code ?? 'UNKNOWN_ERROR', message: result.error?.message ?? 'Unknown error.' } }, + { status }, + ); + } + return NextResponse.json(result, { status: 200 }); +} diff --git a/src/app/api/agents/mail/read/route.ts b/src/app/api/agents/mail/read/route.ts new file mode 100644 index 0000000..e8a6593 --- /dev/null +++ b/src/app/api/agents/mail/read/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { readAgentMessage } from '../../../../../lib/agent-mail'; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON.' } }, + { status: 400 }, + ); + } + + const parsed = body as { agent?: string; message?: string }; + const result = await readAgentMessage({ + agent: parsed.agent ?? '', + message: parsed.message ?? '', + }); + + if (!result.ok) { + const status = result.error?.code === 'READ_FORBIDDEN' ? 403 : result.error?.code === 'AGENT_NOT_FOUND' || result.error?.code === 'MESSAGE_NOT_FOUND' ? 404 : 400; + return NextResponse.json( + { ok: false, error: { code: result.error?.code ?? 'UNKNOWN_ERROR', message: result.error?.message ?? 'Unknown error.' } }, + { status }, + ); + } + return NextResponse.json(result, { status: 200 }); +} diff --git a/src/app/api/agents/mail/route.ts b/src/app/api/agents/mail/route.ts new file mode 100644 index 0000000..4cfc61f --- /dev/null +++ b/src/app/api/agents/mail/route.ts @@ -0,0 +1,151 @@ +import { NextResponse } from 'next/server'; +import { + ackAgentMessage, + inboxAgentMessages, + readAgentMessage, + sendAgentMessage, + type MailCommandResponse, +} from '../../../../lib/agent-mail'; + +type ApiError = { code: string; message: string }; + +function parseJsonBody(value: unknown): T | null { + if (!value || typeof value !== 'object') { + return null; + } + return value as T; +} + +function responseStatus(result: MailCommandResponse): number { + if (result.ok) { + return 200; + } + const code = result.error?.code ?? ''; + if (code === 'READ_FORBIDDEN' || code === 'ACK_FORBIDDEN') { + return 403; + } + if ( + code === 'AGENT_NOT_FOUND' || + code === 'MESSAGE_NOT_FOUND' || + code === 'UNKNOWN_SENDER' || + code === 'UNKNOWN_RECIPIENT' + ) { + return 404; + } + if (code === 'INTERNAL_ERROR') { + return 500; + } + return 400; +} + +function toError(result: MailCommandResponse): ApiError { + return { + code: result.error?.code ?? 'UNKNOWN_ERROR', + message: result.error?.message ?? 'Unknown error.', + }; +} + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const agent = searchParams.get('agent') ?? ''; + const state = searchParams.get('state') ?? undefined; + const bead = searchParams.get('bead') ?? undefined; + const limitParam = searchParams.get('limit'); + const limit = limitParam ? Number.parseInt(limitParam, 10) : undefined; + + const result = await inboxAgentMessages({ + agent, + state: state as 'unread' | 'read' | 'acked' | undefined, + bead, + limit, + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: toError(result) }, { status: responseStatus(result) }); + } + return NextResponse.json(result, { status: 200 }); +} + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON.' } }, + { status: 400 }, + ); + } + + const parsed = parseJsonBody<{ + from?: string; + to?: string; + bead?: string; + category?: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO'; + subject?: string; + body?: string; + thread?: string; + }>(body); + + if (!parsed) { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_BODY', message: 'Request body must be an object.' } }, + { status: 400 }, + ); + } + + const result = await sendAgentMessage({ + from: parsed.from ?? '', + to: parsed.to ?? '', + bead: parsed.bead ?? '', + category: (parsed.category ?? '') as 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO', + subject: parsed.subject ?? '', + body: parsed.body ?? '', + thread: parsed.thread, + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: toError(result) }, { status: responseStatus(result) }); + } + return NextResponse.json(result, { status: 200 }); +} + +export async function PATCH(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON.' } }, + { status: 400 }, + ); + } + + const parsed = parseJsonBody<{ action?: string; agent?: string; message?: string }>(body); + if (!parsed || !parsed.action) { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_ACTION', message: '`action` is required and must be read or ack.' } }, + { status: 400 }, + ); + } + + const action = parsed.action; + if (action !== 'read' && action !== 'ack') { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_ACTION', message: '`action` must be read or ack.' } }, + { status: 400 }, + ); + } + + const result = + action === 'read' + ? await readAgentMessage({ agent: parsed.agent ?? '', message: parsed.message ?? '' }) + : await ackAgentMessage({ agent: parsed.agent ?? '', message: parsed.message ?? '' }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: toError(result) }, { status: responseStatus(result) }); + } + return NextResponse.json(result, { status: 200 }); +} diff --git a/src/app/api/agents/reservations/route.ts b/src/app/api/agents/reservations/route.ts new file mode 100644 index 0000000..a94b143 --- /dev/null +++ b/src/app/api/agents/reservations/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from 'next/server'; +import { + releaseAgentReservation, + reserveAgentScope, + statusAgentReservations, + type ReservationCommandResponse, +} from '../../../../lib/agent-reservations'; + +type ApiError = { code: string; message: string }; + +function responseStatus(result: ReservationCommandResponse): number { + if (result.ok) { + return 200; + } + const code = result.error?.code ?? ''; + if (code === 'RELEASE_FORBIDDEN') { + return 403; + } + if (code === 'AGENT_NOT_FOUND' || code === 'RESERVATION_NOT_FOUND') { + return 404; + } + if (code === 'INTERNAL_ERROR') { + return 500; + } + return 400; +} + +function toError(result: ReservationCommandResponse): ApiError { + return { + code: result.error?.code ?? 'UNKNOWN_ERROR', + message: result.error?.message ?? 'Unknown error.', + }; +} + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const agent = searchParams.get('agent') ?? undefined; + const bead = searchParams.get('bead') ?? undefined; + const result = await statusAgentReservations({ agent, bead }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: toError(result) }, { status: responseStatus(result) }); + } + return NextResponse.json(result, { status: 200 }); +} + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON.' } }, + { status: 400 }, + ); + } + + const parsed = body as { + agent?: string; + scope?: string; + bead?: string; + ttl?: number; + takeoverStale?: boolean; + }; + + const result = await reserveAgentScope({ + agent: parsed.agent ?? '', + scope: parsed.scope ?? '', + bead: parsed.bead ?? '', + ttl: parsed.ttl, + takeoverStale: parsed.takeoverStale, + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: toError(result) }, { status: responseStatus(result) }); + } + return NextResponse.json(result, { status: 200 }); +} + +export async function DELETE(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { ok: false, error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON.' } }, + { status: 400 }, + ); + } + + const parsed = body as { agent?: string; scope?: string }; + const result = await releaseAgentReservation({ + agent: parsed.agent ?? '', + scope: parsed.scope ?? '', + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: toError(result) }, { status: responseStatus(result) }); + } + return NextResponse.json(result, { status: 200 }); +} diff --git a/tests/api/agents-mail.test.ts b/tests/api/agents-mail.test.ts new file mode 100644 index 0000000..63b5cd9 --- /dev/null +++ b/tests/api/agents-mail.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { GET as getMail, POST as postMail, PATCH as patchMail } from '../../src/app/api/agents/mail/route'; +import { GET as getReservations } from '../../src/app/api/agents/reservations/route'; + +async function readJson(response: Response): Promise { + const text = await response.text(); + return text ? JSON.parse(text) : {}; +} + +test('GET /api/agents/mail returns AGENT_NOT_FOUND for unknown agent', async () => { + const response = await getMail(new Request('http://localhost/api/agents/mail?agent=nonexistent')); + const data = await readJson(response); + assert.equal(response.status, 404); + assert.equal(data.ok, false); + assert.equal(data.error?.code, 'AGENT_NOT_FOUND'); +}); + +test('POST /api/agents/mail returns structured error on missing sender/recipient', async () => { + const response = await postMail( + new Request('http://localhost/api/agents/mail', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + from: 'unknown', + to: 'missing', + bead: 'bb-123', + category: 'INFO', + subject: 'hello', + body: 'world', + }), + }), + ); + const data = await readJson(response); + assert.equal(response.status, 404); + assert.equal(data.ok, false); + assert.equal(typeof data.error?.code, 'string'); + assert.equal(typeof data.error?.message, 'string'); +}); + +test('PATCH /api/agents/mail validates action', async () => { + const response = await patchMail( + new Request('http://localhost/api/agents/mail', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'nope', + agent: 'x', + message: 'y', + }), + }), + ); + const data = await readJson(response); + assert.equal(response.status, 400); + assert.equal(data.ok, false); + assert.equal(data.error?.code, 'INVALID_ACTION'); +}); + +test('GET /api/agents/reservations returns AGENT_NOT_FOUND for unknown agent', async () => { + const response = await getReservations( + new Request('http://localhost/api/agents/reservations?agent=nonexistent'), + ); + const data = await readJson(response); + assert.equal(response.status, 404); + assert.equal(data.ok, false); + assert.equal(data.error?.code, 'AGENT_NOT_FOUND'); +});