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> {
|
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 = {
|
||||||
|
|||||||
@ -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
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