fix(server): 404/400 error handling and consistent delegate behavior; add server route tests
This commit is contained in:
parent
b5a70b226b
commit
7eff25129f
@ -9,17 +9,26 @@ export class RemoteError extends Error {
|
||||
|
||||
async function request<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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();
|
||||
if (!res.ok) {
|
||||
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 = {
|
||||
|
||||
@ -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 { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
||||
import { createDecision, listDecisions } from '../core/services/decisionService.js';
|
||||
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
|
||||
import { getStatus, updateStatus } from '../core/services/statusService.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';
|
||||
|
||||
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> {
|
||||
app.get('/status', async () => ({ body: getStatus(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 });
|
||||
});
|
||||
|
||||
app.post('/tasks', async (request) => {
|
||||
return createTask(cwd, request.body as Partial<Task>);
|
||||
app.post('/tasks', async (request, reply) => {
|
||||
try {
|
||||
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 { task, body } = getTask(cwd, id);
|
||||
return { task, body };
|
||||
try {
|
||||
const { task, body } = getTask(cwd, id);
|
||||
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 patch = request.body as Partial<Task>;
|
||||
if (patch.status === 'in_progress' && patch.assignedTo) {
|
||||
@ -35,22 +52,44 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
if (patch.status === 'done') {
|
||||
return doneTask(cwd, id);
|
||||
}
|
||||
return { error: 'Unsupported patch' };
|
||||
return badRequest(reply, 'Unsupported patch');
|
||||
});
|
||||
|
||||
app.get('/handoffs', async () => listHandoffs(cwd));
|
||||
app.post('/handoffs', async (request) => createHandoff(cwd, request.body as Partial<Handoff>));
|
||||
app.get('/handoffs/:id', async (request) => {
|
||||
app.post('/handoffs', async (request, reply) => {
|
||||
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 { handoff, body } = getHandoff(cwd, id);
|
||||
return { handoff, body };
|
||||
try {
|
||||
const { handoff, body } = getHandoff(cwd, id);
|
||||
return { handoff, body };
|
||||
} catch {
|
||||
return notFound(reply, 'Handoff');
|
||||
}
|
||||
});
|
||||
|
||||
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.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) => {
|
||||
const { q } = request.query as { q: string };
|
||||
return searchMemory(cwd, q ?? '');
|
||||
@ -58,9 +97,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
|
||||
app.post('/delegate', async (request) => {
|
||||
const { auto } = request.query as { auto?: string };
|
||||
const config = loadConfig(cwd);
|
||||
const suggestion = suggestDelegation(cwd);
|
||||
if (!suggestion) return { suggestion: null };
|
||||
if (auto === 'true') {
|
||||
const shouldAuto = auto === 'true' || config.delegationMode === 'auto';
|
||||
if (shouldAuto) {
|
||||
const handoff = autoDelegate(cwd);
|
||||
return { suggestion, handoff };
|
||||
}
|
||||
|
||||
63
tests/server.test.ts
Normal file
63
tests/server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user