310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
import { FastifyInstance, FastifyReply } from 'fastify';
|
|
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask } from '../core/services/taskService.js';
|
|
import { getTaskActivity } from '../core/services/activityService.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 { renderBoardHtml } from './board.js';
|
|
import { renderTeamHtml } from './team.js';
|
|
import { eventBus, emitChange } from './events.js';
|
|
import type { AgentHubEvent } from './events.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> {
|
|
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
|
// /decisions on the same origin; no build step, no deps. Cached once — the
|
|
// markup is constant, only the data it fetches changes.
|
|
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
|
|
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
|
|
|
|
// Team hierarchy page: roles tree with per-agent free/busy state.
|
|
app.get('/team', async (_request, reply) => {
|
|
const teamHtml = renderTeamHtml(cwd);
|
|
return reply.type('text/html; charset=utf-8').send(teamHtml);
|
|
});
|
|
|
|
// ─── Server-Sent Events ──────────────────────────────────────────────────
|
|
// GET /events?role=<role>
|
|
//
|
|
// Keeps the connection open and streams JSON-encoded AgentHubEvent objects
|
|
// as SSE data lines. Sends a keepalive comment (": \n\n") every 25 s so
|
|
// proxies and clients detect the connection is still alive.
|
|
//
|
|
// Optional ?role= filter: tasks whose role doesn't match are dropped
|
|
// server-side. All handoff / decision / memory events are always forwarded.
|
|
//
|
|
// Both REST mutations and direct local-CLI writes emit events: REST routes
|
|
// publish via emitChange(), and the filesystem watcher (fsWatch.ts) observes
|
|
// the entity directories and emits for any other write. A shared dedup cache
|
|
// (events.ts) ensures each change is delivered exactly once.
|
|
app.get('/events', async (request, reply) => {
|
|
const { role } = request.query as { role?: string };
|
|
|
|
// Take full control of the raw response so Fastify doesn't interfere.
|
|
reply.hijack();
|
|
|
|
const raw = reply.raw;
|
|
raw.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
});
|
|
raw.flushHeaders();
|
|
|
|
const listener = (event: AgentHubEvent) => {
|
|
// Server-side role filter: skip tasks that belong to a different role.
|
|
// Handoffs, decisions and memory always pass through.
|
|
if (role && event.type === 'task' && event.role !== undefined && event.role !== role) {
|
|
return;
|
|
}
|
|
raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
};
|
|
|
|
eventBus.on('change', listener);
|
|
|
|
const keepAliveTimer = setInterval(() => {
|
|
raw.write(':\n\n');
|
|
}, 25_000);
|
|
|
|
// Clean up when the client disconnects (or the server closes).
|
|
request.raw.on('close', () => {
|
|
clearInterval(keepAliveTimer);
|
|
eventBus.off('change', listener);
|
|
});
|
|
});
|
|
|
|
// ─── Presence ──────────────────────────────────────────────────────────────
|
|
// POST /announce { agent, role?, action? } — an agent reports in on connect.
|
|
// Ephemeral: broadcast to SSE subscribers only, nothing is written to disk.
|
|
app.post('/announce', async (request, reply) => {
|
|
const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string };
|
|
if (!agent) return badRequest(reply, 'agent is required');
|
|
const ev: AgentHubEvent = {
|
|
type: 'agent',
|
|
action: action === 'left' ? 'left' : 'joined',
|
|
id: agent,
|
|
role,
|
|
};
|
|
eventBus.publish(ev);
|
|
return { ok: true, agent, action: ev.action };
|
|
});
|
|
|
|
// ─── Status ──────────────────────────────────────────────────────────────
|
|
app.get('/status', async () => ({ body: getStatus(cwd) }));
|
|
app.post('/status/update', async () => ({ body: updateStatus(cwd) }));
|
|
|
|
// ─── Tasks ───────────────────────────────────────────────────────────────
|
|
app.get('/tasks', async (request) => {
|
|
const { status, role } = request.query as { status?: string; role?: string };
|
|
return listTasks(cwd, { status, role });
|
|
});
|
|
|
|
app.post('/tasks', async (request, reply) => {
|
|
let task: Task;
|
|
try {
|
|
task = createTask(cwd, request.body as Partial<Task>);
|
|
} catch (err) {
|
|
return badRequest(reply, err instanceof Error ? err.message : 'Invalid task');
|
|
}
|
|
emitChange(
|
|
{
|
|
type: 'task',
|
|
action: 'created',
|
|
id: task.id,
|
|
title: task.title,
|
|
status: task.status,
|
|
role: task.role,
|
|
assignedTo: task.assignedTo,
|
|
},
|
|
task.updatedAt,
|
|
);
|
|
return task;
|
|
});
|
|
|
|
app.get('/tasks/:id', async (request, reply) => {
|
|
const { id } = request.params as { id: string };
|
|
try {
|
|
const { task, body } = getTask(cwd, id);
|
|
return { task, body };
|
|
} catch {
|
|
return notFound(reply, 'Task');
|
|
}
|
|
});
|
|
|
|
app.get('/tasks/:id/activity', async (request, reply) => {
|
|
const { id } = request.params as { id: string };
|
|
try {
|
|
return getTaskActivity(cwd, id);
|
|
} catch {
|
|
return notFound(reply, 'Task');
|
|
}
|
|
});
|
|
|
|
app.patch('/tasks/:id', async (request, reply) => {
|
|
const { id } = request.params as { id: string };
|
|
const patch = request.body as Partial<Task>;
|
|
|
|
// Assign without claiming: address an open task to an agent (no status
|
|
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
|
|
if (patch.assignedTo !== undefined && patch.status === undefined) {
|
|
const assigned = assignTask(cwd, id, patch.assignedTo);
|
|
emitChange(
|
|
{
|
|
type: 'task',
|
|
action: 'updated',
|
|
id: assigned.id,
|
|
title: assigned.title,
|
|
status: assigned.status,
|
|
role: assigned.role,
|
|
assignedTo: assigned.assignedTo,
|
|
},
|
|
assigned.updatedAt,
|
|
);
|
|
return assigned;
|
|
}
|
|
|
|
let task: Task;
|
|
switch (patch.status) {
|
|
case 'in_progress':
|
|
if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)');
|
|
task = claimTask(cwd, id, patch.assignedTo);
|
|
break;
|
|
case 'done':
|
|
task = doneTask(cwd, id, {
|
|
doneBy: patch.doneBy as string | undefined,
|
|
doneTokens: patch.doneTokens as number | undefined,
|
|
doneDuration: patch.doneDuration as number | undefined,
|
|
});
|
|
break;
|
|
case 'review':
|
|
task = reviewTask(cwd, id);
|
|
break;
|
|
case 'cancelled':
|
|
task = cancelTask(cwd, id);
|
|
break;
|
|
case 'open':
|
|
task = reopenTask(cwd, id);
|
|
break;
|
|
default:
|
|
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
|
|
}
|
|
|
|
emitChange(
|
|
{
|
|
type: 'task',
|
|
action: 'updated',
|
|
id: task.id,
|
|
title: task.title,
|
|
status: task.status,
|
|
role: task.role,
|
|
assignedTo: task.assignedTo,
|
|
},
|
|
task.updatedAt,
|
|
);
|
|
return task;
|
|
});
|
|
|
|
// ─── Handoffs ────────────────────────────────────────────────────────────
|
|
app.get('/handoffs', async () => listHandoffs(cwd));
|
|
app.post('/handoffs', async (request, reply) => {
|
|
let handoff: Handoff;
|
|
try {
|
|
handoff = createHandoff(cwd, request.body as Partial<Handoff>);
|
|
} catch (err) {
|
|
return badRequest(reply, err instanceof Error ? err.message : 'Invalid handoff');
|
|
}
|
|
emitChange(
|
|
{
|
|
type: 'handoff',
|
|
action: 'created',
|
|
id: handoff.id,
|
|
title: handoff.summary,
|
|
role: handoff.toRole,
|
|
assignedTo: handoff.toAgent,
|
|
},
|
|
handoff.createdAt,
|
|
);
|
|
return handoff;
|
|
});
|
|
app.get('/handoffs/:id', async (request, reply) => {
|
|
const { id } = request.params as { id: string };
|
|
try {
|
|
const { handoff, body } = getHandoff(cwd, id);
|
|
return { handoff, body };
|
|
} catch {
|
|
return notFound(reply, 'Handoff');
|
|
}
|
|
});
|
|
|
|
// ─── Decisions ───────────────────────────────────────────────────────────
|
|
app.get('/decisions', async () => listDecisions(cwd));
|
|
app.post('/decisions', async (request, reply) => {
|
|
let decision: Decision;
|
|
try {
|
|
decision = createDecision(cwd, request.body as Partial<Decision>);
|
|
} catch (err) {
|
|
return badRequest(reply, err instanceof Error ? err.message : 'Invalid decision');
|
|
}
|
|
emitChange(
|
|
{
|
|
type: 'decision',
|
|
action: 'created',
|
|
id: decision.id,
|
|
title: decision.title,
|
|
},
|
|
decision.updatedAt,
|
|
);
|
|
return decision;
|
|
});
|
|
|
|
// ─── Memory ──────────────────────────────────────────────────────────────
|
|
app.get('/memory', async () => listMemory(cwd));
|
|
app.post('/memory', async (request, reply) => {
|
|
let memory: Memory;
|
|
try {
|
|
memory = addMemory(cwd, request.body as Partial<Memory>);
|
|
} catch (err) {
|
|
return badRequest(reply, err instanceof Error ? err.message : 'Invalid memory');
|
|
}
|
|
emitChange(
|
|
{
|
|
type: 'memory',
|
|
action: 'created',
|
|
id: memory.id,
|
|
title: memory.title,
|
|
},
|
|
memory.updatedAt,
|
|
);
|
|
return memory;
|
|
});
|
|
app.get('/memory/search', async (request) => {
|
|
const { q } = request.query as { q: string };
|
|
return searchMemory(cwd, q ?? '');
|
|
});
|
|
|
|
// ─── Delegate ────────────────────────────────────────────────────────────
|
|
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 };
|
|
const shouldAuto = auto === 'true' || config.delegationMode === 'auto';
|
|
if (shouldAuto) {
|
|
const handoff = autoDelegate(cwd);
|
|
return { suggestion, handoff };
|
|
}
|
|
return { suggestion };
|
|
});
|
|
}
|