feat(api): add agent mail and reservations routes

This commit is contained in:
ZenchantLive 2026-03-03 18:32:11 -08:00
parent dc7f20148c
commit dcca324bfb
5 changed files with 379 additions and 0 deletions

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { ackAgentMessage } from '../../../../../lib/agent-mail';
export async function POST(request: Request): Promise<Response> {
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 });
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { readAgentMessage } from '../../../../../lib/agent-mail';
export async function POST(request: Request): Promise<Response> {
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 });
}

View file

@ -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<T>(value: unknown): T | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as T;
}
function responseStatus<T>(result: MailCommandResponse<T>): 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<unknown>): 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<Response> {
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<Response> {
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<Response> {
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 });
}

View file

@ -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<T>(result: ReservationCommandResponse<T>): 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<unknown>): 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<Response> {
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<Response> {
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<Response> {
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 });
}

View file

@ -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<any> {
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');
});