fix(server): 404/400 error handling and consistent delegate behavior; add server route tests

This commit is contained in:
chahinebrini 2026-06-25 12:40:11 +02:00
parent b5a70b226b
commit 7eff25129f
3 changed files with 134 additions and 21 deletions

View File

@ -9,17 +9,26 @@ export class RemoteError extends Error {
async function request<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<T> { async function request<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<T> {
const url = `${baseUrl}${path}`; const url = `${baseUrl}${path}`;
const res = await fetch(url, { let res: Response;
try {
res = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new RemoteError(0, `Cannot reach AgentHub server at ${baseUrl}: ${message}`);
}
const text = await res.text(); const text = await res.text();
if (!res.ok) { if (!res.ok) {
throw new RemoteError(res.status, text || `HTTP ${res.status}`); throw new RemoteError(res.status, text || `HTTP ${res.status}`);
} }
return text ? (JSON.parse(text) as T) : (undefined as T); if (!text) {
throw new RemoteError(res.status, 'Empty response from server');
}
return JSON.parse(text) as T;
} }
export const remoteClient = { export const remoteClient = {

View File

@ -1,12 +1,21 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance, FastifyReply } from 'fastify';
import { createTask, listTasks, getTask, claimTask, doneTask } from '../core/services/taskService.js'; import { createTask, listTasks, getTask, claimTask, doneTask } from '../core/services/taskService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
import { getStatus, updateStatus } from '../core/services/statusService.js'; import { getStatus, updateStatus } from '../core/services/statusService.js';
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
import { loadConfig } from '../core/config.js';
import type { Task, Handoff, Decision, Memory } from '../core/schema.js'; import type { Task, Handoff, Decision, Memory } from '../core/schema.js';
function notFound(reply: FastifyReply, resource: string) {
return reply.status(404).send({ error: `${resource} not found` });
}
function badRequest(reply: FastifyReply, message: string) {
return reply.status(400).send({ error: message });
}
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> { export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
app.get('/status', async () => ({ body: getStatus(cwd) })); app.get('/status', async () => ({ body: getStatus(cwd) }));
app.post('/status/update', async () => ({ body: updateStatus(cwd) })); app.post('/status/update', async () => ({ body: updateStatus(cwd) }));
@ -16,17 +25,25 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
return listTasks(cwd, { status, role }); return listTasks(cwd, { status, role });
}); });
app.post('/tasks', async (request) => { app.post('/tasks', async (request, reply) => {
try {
return createTask(cwd, request.body as Partial<Task>); return createTask(cwd, request.body as Partial<Task>);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid task');
}
}); });
app.get('/tasks/:id', async (request) => { app.get('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
try {
const { task, body } = getTask(cwd, id); const { task, body } = getTask(cwd, id);
return { task, body }; return { task, body };
} catch {
return notFound(reply, 'Task');
}
}); });
app.patch('/tasks/:id', async (request) => { app.patch('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const patch = request.body as Partial<Task>; const patch = request.body as Partial<Task>;
if (patch.status === 'in_progress' && patch.assignedTo) { if (patch.status === 'in_progress' && patch.assignedTo) {
@ -35,22 +52,44 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
if (patch.status === 'done') { if (patch.status === 'done') {
return doneTask(cwd, id); return doneTask(cwd, id);
} }
return { error: 'Unsupported patch' }; return badRequest(reply, 'Unsupported patch');
}); });
app.get('/handoffs', async () => listHandoffs(cwd)); app.get('/handoffs', async () => listHandoffs(cwd));
app.post('/handoffs', async (request) => createHandoff(cwd, request.body as Partial<Handoff>)); app.post('/handoffs', async (request, reply) => {
app.get('/handoffs/:id', async (request) => { try {
return createHandoff(cwd, request.body as Partial<Handoff>);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid handoff');
}
});
app.get('/handoffs/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
try {
const { handoff, body } = getHandoff(cwd, id); const { handoff, body } = getHandoff(cwd, id);
return { handoff, body }; return { handoff, body };
} catch {
return notFound(reply, 'Handoff');
}
}); });
app.get('/decisions', async () => listDecisions(cwd)); app.get('/decisions', async () => listDecisions(cwd));
app.post('/decisions', async (request) => createDecision(cwd, request.body as Partial<Decision>)); app.post('/decisions', async (request, reply) => {
try {
return createDecision(cwd, request.body as Partial<Decision>);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid decision');
}
});
app.get('/memory', async () => listMemory(cwd)); app.get('/memory', async () => listMemory(cwd));
app.post('/memory', async (request) => addMemory(cwd, request.body as Partial<Memory>)); app.post('/memory', async (request, reply) => {
try {
return addMemory(cwd, request.body as Partial<Memory>);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid memory');
}
});
app.get('/memory/search', async (request) => { app.get('/memory/search', async (request) => {
const { q } = request.query as { q: string }; const { q } = request.query as { q: string };
return searchMemory(cwd, q ?? ''); return searchMemory(cwd, q ?? '');
@ -58,9 +97,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
app.post('/delegate', async (request) => { app.post('/delegate', async (request) => {
const { auto } = request.query as { auto?: string }; const { auto } = request.query as { auto?: string };
const config = loadConfig(cwd);
const suggestion = suggestDelegation(cwd); const suggestion = suggestDelegation(cwd);
if (!suggestion) return { suggestion: null }; if (!suggestion) return { suggestion: null };
if (auto === 'true') { const shouldAuto = auto === 'true' || config.delegationMode === 'auto';
if (shouldAuto) {
const handoff = autoDelegate(cwd); const handoff = autoDelegate(cwd);
return { suggestion, handoff }; return { suggestion, handoff };
} }

63
tests/server.test.ts Normal file
View File

@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
describe('server routes', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-server-'));
init(cwd, { projectName: 'server-test', yes: true });
app = buildApp(cwd);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('creates a task via POST /tasks', async () => {
const res = await app.inject({
method: 'POST',
url: '/tasks',
payload: { title: 'API task', role: 'implementer' },
});
expect(res.statusCode).toBe(200);
const task = JSON.parse(res.payload);
expect(task.id).toBe('TSK-0001');
});
it('lists tasks via GET /tasks', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'GET', url: '/tasks' });
expect(JSON.parse(res.payload)).toHaveLength(1);
});
it('returns 404 for unknown task', async () => {
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' });
expect(res.statusCode).toBe(404);
});
it('returns 400 for unsupported patch', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'unknown' } });
expect(res.statusCode).toBe(400);
});
it('updates status via POST /status/update', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'POST', url: '/status/update' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).body).toContain('Active tasks: 1');
});
it('searches memory via GET /memory/search', async () => {
await app.inject({ method: 'POST', url: '/memory', payload: { title: 'DNS cache', category: 'technical', content: 'Use TTL' } });
const res = await app.inject({ method: 'GET', url: '/memory/search?q=TTL' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload)).toHaveLength(1);
});
});