Files
boss/src/server.ts

222 lines
6.6 KiB
TypeScript

import path from "node:path";
import Fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { BossEngine } from "./engine.js";
const engine = new BossEngine();
const app = Fastify({ logger: process.env.BOSS_DEBUG === "1" });
app.setErrorHandler((error, request, reply) => {
const message =
typeof error === "object" && error !== null && "message" in error
? String(error.message)
: "Internal Server Error";
const normalized = message.toLowerCase();
if (normalized.includes("not found")) {
return reply.status(404).send({ error: "Not Found", message });
}
if (
normalized.includes("is not assigned to worker") ||
normalized.includes("is not currently executing") ||
normalized.includes("does not accept worker updates")
) {
return reply.status(409).send({ error: "Conflict", message });
}
if (
normalized.includes("required") ||
normalized.includes("archived")
) {
return reply.status(400).send({ error: "Bad Request", message });
}
request.log.error(error);
return reply.status(500).send({ error: "Internal Server Error", message });
});
await app.register(fastifyStatic, {
root: path.resolve(process.cwd(), "public"),
prefix: "/",
});
function ok() {
return { ok: true };
}
app.get("/api/health", async () => ({
status: "ok",
sessions: engine.getState().sessions.length,
workers: engine.getState().workers.length,
}));
app.get("/api/bootstrap", async () => engine.bootstrap());
app.get("/api/events", async (request) => {
const query = request.query as { limit?: string };
const limit = Number(query.limit ?? 100);
return engine.listEvents(Number.isFinite(limit) ? limit : 100);
});
app.get("/api/sessions", async () => engine.listSessions());
app.post("/api/sessions", async (request) => {
const body = (request.body ?? {}) as { title?: string };
return engine.createSession(body.title);
});
app.post("/api/sessions/:sessionId/archive", async (request) => {
const params = request.params as { sessionId: string };
return engine.archiveSession(params.sessionId);
});
app.get("/api/sessions/:sessionId", async (request) => {
const params = request.params as { sessionId: string };
return engine.getSession(params.sessionId);
});
app.get("/api/tasks/:taskId", async (request) => {
const params = request.params as { taskId: string };
return engine.getTask(params.taskId);
});
app.post("/api/sessions/:sessionId/messages", async (request) => {
const params = request.params as { sessionId: string };
const body = (request.body ?? {}) as { content?: string; channel?: string };
return engine.addMessage(params.sessionId, body.content ?? "", body.channel ?? "web");
});
app.get("/api/events/stream", async (_request, reply) => {
reply.raw.setHeader("Content-Type", "text/event-stream");
reply.raw.setHeader("Cache-Control", "no-cache");
reply.raw.setHeader("Connection", "keep-alive");
reply.raw.flushHeaders?.();
const unsubscribe = engine.events.subscribe((event) => {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
});
const interval = setInterval(() => {
reply.raw.write(": ping\n\n");
}, 15_000);
reply.raw.on("close", () => {
clearInterval(interval);
unsubscribe();
reply.raw.end();
});
});
app.get("/api/workers", async () => engine.getState().workers);
app.get("/api/workers/:workerId", async (request) => {
const params = request.params as { workerId: string };
return engine.getWorker(params.workerId);
});
app.post("/api/workers/register", async (request) => {
const body = request.body as {
name?: string;
os?: "windows" | "macos" | "linux";
capabilities?: string[];
};
return engine.registerWorker({
name: body.name ?? "worker",
os: body.os ?? "linux",
capabilities: body.capabilities ?? ["terminal"],
});
});
app.post("/api/workers/:workerId/heartbeat", async (request) => {
const params = request.params as { workerId: string };
const body = (request.body ?? {}) as { load?: number };
return engine.heartbeat(params.workerId, body.load ?? 0);
});
app.post("/api/workers/:workerId/offline", async (request) => {
const params = request.params as { workerId: string };
return engine.markWorkerOffline(params.workerId);
});
app.post("/api/workers/:workerId/claim-next", async (request) => {
const params = request.params as { workerId: string };
return {
task: engine.claimNextTask(params.workerId),
};
});
app.post("/api/tasks/:taskId/progress", async (request) => {
const params = request.params as { taskId: string };
const body = request.body as {
workerId: string;
progressPercent: number;
summary: string;
currentStep: string;
nextStep: string;
};
return engine.reportProgress(params.taskId, body.workerId, {
progressPercent: body.progressPercent,
summary: body.summary,
currentStep: body.currentStep,
nextStep: body.nextStep,
});
});
app.post("/api/tasks/:taskId/complete", async (request) => {
const params = request.params as { taskId: string };
const body = request.body as {
workerId: string;
summary: string;
};
return engine.completeTask(params.taskId, body.workerId, body.summary);
});
app.post("/api/tasks/:taskId/fail", async (request) => {
const params = request.params as { taskId: string };
const body = request.body as {
workerId: string;
errorMessage: string;
};
return engine.failTask(params.taskId, body.workerId, body.errorMessage);
});
app.post("/api/tasks/:taskId/pause", async (request) => {
const params = request.params as { taskId: string };
return engine.pauseTask(params.taskId);
});
app.post("/api/tasks/:taskId/cancel", async (request) => {
const params = request.params as { taskId: string };
return engine.cancelTask(params.taskId);
});
app.post("/api/tasks/:taskId/resume", async (request) => {
const params = request.params as { taskId: string };
return engine.resumeTask(params.taskId);
});
app.post("/api/tasks/:taskId/requeue", async (request) => {
const params = request.params as { taskId: string };
return engine.requeueTask(params.taskId);
});
app.post("/api/approvals/:approvalId/respond", async (request) => {
const params = request.params as { approvalId: string };
const body = request.body as {
approved: boolean;
responder?: string;
};
return engine.respondApproval(params.approvalId, body.approved, body.responder ?? "user");
});
app.post("/api/demo/reset", async () => {
engine.store.reset();
return ok();
});
app.post("/api/reconcile", async () => engine.reconcileNow());
const port = Number(process.env.PORT ?? 43210);
await app.listen({ port, host: "0.0.0.0" });