feat(api): add agent mail and reservations routes
This commit is contained in:
parent
dc7f20148c
commit
dcca324bfb
5 changed files with 379 additions and 0 deletions
29
src/app/api/agents/mail/ack/route.ts
Normal file
29
src/app/api/agents/mail/ack/route.ts
Normal 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 });
|
||||
}
|
||||
29
src/app/api/agents/mail/read/route.ts
Normal file
29
src/app/api/agents/mail/read/route.ts
Normal 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 });
|
||||
}
|
||||
151
src/app/api/agents/mail/route.ts
Normal file
151
src/app/api/agents/mail/route.ts
Normal 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 });
|
||||
}
|
||||
103
src/app/api/agents/reservations/route.ts
Normal file
103
src/app/api/agents/reservations/route.ts
Normal 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 });
|
||||
}
|
||||
67
tests/api/agents-mail.test.ts
Normal file
67
tests/api/agents-mail.test.ts
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue