Refactor monolithic heartbeat service, AgentConfigForm, and CLI heartbeat-run into a proper adapter registry pattern. Each adapter type (process, claude-local, codex-local, http) gets its own module with server-side execution logic, CLI invocation, and UI config form. Significantly reduces file sizes and enables adding new adapters without touching core code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
434 lines
12 KiB
TypeScript
434 lines
12 KiB
TypeScript
import { Router } from "express";
|
|
import type { Db } from "@paperclip/db";
|
|
import {
|
|
createAgentKeySchema,
|
|
createAgentSchema,
|
|
wakeAgentSchema,
|
|
updateAgentSchema,
|
|
} from "@paperclip/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { agentService, heartbeatService, logActivity } from "../services/index.js";
|
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
import { listAdapterModels } from "../adapters/index.js";
|
|
|
|
export function agentRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = agentService(db);
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
router.get("/adapters/:type/models", (req, res) => {
|
|
const type = req.params.type as string;
|
|
const models = listAdapterModels(type);
|
|
res.json(models);
|
|
});
|
|
|
|
router.get("/companies/:companyId/agents", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.list(companyId);
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/companies/:companyId/org", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const tree = await svc.orgForCompany(companyId);
|
|
res.json(tree);
|
|
});
|
|
|
|
router.get("/agents/me", async (req, res) => {
|
|
if (req.actor.type !== "agent" || !req.actor.agentId) {
|
|
res.status(401).json({ error: "Agent authentication required" });
|
|
return;
|
|
}
|
|
const agent = await svc.getById(req.actor.agentId);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
const chainOfCommand = await svc.getChainOfCommand(agent.id);
|
|
res.json({ ...agent, chainOfCommand });
|
|
});
|
|
|
|
router.get("/agents/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const agent = await svc.getById(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, agent.companyId);
|
|
const chainOfCommand = await svc.getChainOfCommand(agent.id);
|
|
res.json({ ...agent, chainOfCommand });
|
|
});
|
|
|
|
router.get("/agents/:id/runtime-state", async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const agent = await svc.getById(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, agent.companyId);
|
|
|
|
const state = await heartbeat.getRuntimeState(id);
|
|
res.json(state);
|
|
});
|
|
|
|
router.post("/agents/:id/runtime-state/reset-session", async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const agent = await svc.getById(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, agent.companyId);
|
|
|
|
const state = await heartbeat.resetRuntimeSession(id);
|
|
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: "user",
|
|
actorId: req.actor.userId ?? "board",
|
|
action: "agent.runtime_session_reset",
|
|
entityType: "agent",
|
|
entityId: id,
|
|
});
|
|
|
|
res.json(state);
|
|
});
|
|
|
|
router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
if (req.actor.type === "agent") {
|
|
assertBoard(req);
|
|
}
|
|
|
|
const agent = await svc.create(companyId, {
|
|
...req.body,
|
|
status: "idle",
|
|
spentMonthlyCents: 0,
|
|
lastHeartbeatAt: null,
|
|
});
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "agent.created",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
details: { name: agent.name, role: agent.role },
|
|
});
|
|
|
|
res.status(201).json(agent);
|
|
});
|
|
|
|
router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
|
|
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
|
res.status(403).json({ error: "Agent can only modify itself" });
|
|
return;
|
|
}
|
|
|
|
const agent = await svc.update(id, req.body);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "agent.updated",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
details: req.body,
|
|
});
|
|
|
|
res.json(agent);
|
|
});
|
|
|
|
router.post("/agents/:id/pause", async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const agent = await svc.pause(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
|
|
await heartbeat.cancelActiveForAgent(id);
|
|
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: "user",
|
|
actorId: req.actor.userId ?? "board",
|
|
action: "agent.paused",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
});
|
|
|
|
res.json(agent);
|
|
});
|
|
|
|
router.post("/agents/:id/resume", async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const agent = await svc.resume(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: "user",
|
|
actorId: req.actor.userId ?? "board",
|
|
action: "agent.resumed",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
});
|
|
|
|
res.json(agent);
|
|
});
|
|
|
|
router.post("/agents/:id/terminate", async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const agent = await svc.terminate(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
|
|
await heartbeat.cancelActiveForAgent(id);
|
|
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: "user",
|
|
actorId: req.actor.userId ?? "board",
|
|
action: "agent.terminated",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
});
|
|
|
|
res.json(agent);
|
|
});
|
|
|
|
router.get("/agents/:id/keys", async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const keys = await svc.listKeys(id);
|
|
res.json(keys);
|
|
});
|
|
|
|
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const key = await svc.createApiKey(id, req.body.name);
|
|
|
|
const agent = await svc.getById(id);
|
|
if (agent) {
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: "user",
|
|
actorId: req.actor.userId ?? "board",
|
|
action: "agent.key_created",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
details: { keyId: key.id, name: key.name },
|
|
});
|
|
}
|
|
|
|
res.status(201).json(key);
|
|
});
|
|
|
|
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
|
|
assertBoard(req);
|
|
const keyId = req.params.keyId as string;
|
|
const revoked = await svc.revokeKey(keyId);
|
|
if (!revoked) {
|
|
res.status(404).json({ error: "Key not found" });
|
|
return;
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const agent = await svc.getById(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, agent.companyId);
|
|
|
|
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
|
res.status(403).json({ error: "Agent can only invoke itself" });
|
|
return;
|
|
}
|
|
|
|
const run = await heartbeat.wakeup(id, {
|
|
source: req.body.source,
|
|
triggerDetail: req.body.triggerDetail ?? "manual",
|
|
reason: req.body.reason ?? null,
|
|
payload: req.body.payload ?? null,
|
|
idempotencyKey: req.body.idempotencyKey ?? null,
|
|
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
|
|
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
|
contextSnapshot: {
|
|
triggeredBy: req.actor.type,
|
|
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
|
},
|
|
});
|
|
|
|
if (!run) {
|
|
res.status(202).json({ status: "skipped" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "heartbeat.invoked",
|
|
entityType: "heartbeat_run",
|
|
entityId: run.id,
|
|
details: { agentId: id },
|
|
});
|
|
|
|
res.status(202).json(run);
|
|
});
|
|
|
|
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const agent = await svc.getById(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, agent.companyId);
|
|
|
|
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
|
res.status(403).json({ error: "Agent can only invoke itself" });
|
|
return;
|
|
}
|
|
|
|
const run = await heartbeat.invoke(
|
|
id,
|
|
"on_demand",
|
|
{
|
|
triggeredBy: req.actor.type,
|
|
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
|
},
|
|
"manual",
|
|
{
|
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
|
},
|
|
);
|
|
|
|
if (!run) {
|
|
res.status(202).json({ status: "skipped" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "heartbeat.invoked",
|
|
entityType: "heartbeat_run",
|
|
entityId: run.id,
|
|
details: { agentId: id },
|
|
});
|
|
|
|
res.status(202).json(run);
|
|
});
|
|
|
|
router.get("/companies/:companyId/heartbeat-runs", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const agentId = req.query.agentId as string | undefined;
|
|
const runs = await heartbeat.list(companyId, agentId);
|
|
res.json(runs);
|
|
});
|
|
|
|
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
|
|
assertBoard(req);
|
|
const runId = req.params.runId as string;
|
|
const run = await heartbeat.cancelRun(runId);
|
|
|
|
if (run) {
|
|
await logActivity(db, {
|
|
companyId: run.companyId,
|
|
actorType: "user",
|
|
actorId: req.actor.userId ?? "board",
|
|
action: "heartbeat.cancelled",
|
|
entityType: "heartbeat_run",
|
|
entityId: run.id,
|
|
details: { agentId: run.agentId },
|
|
});
|
|
}
|
|
|
|
res.json(run);
|
|
});
|
|
|
|
router.get("/heartbeat-runs/:runId/events", async (req, res) => {
|
|
const runId = req.params.runId as string;
|
|
const run = await heartbeat.getRun(runId);
|
|
if (!run) {
|
|
res.status(404).json({ error: "Heartbeat run not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, run.companyId);
|
|
|
|
const afterSeq = Number(req.query.afterSeq ?? 0);
|
|
const limit = Number(req.query.limit ?? 200);
|
|
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
|
|
res.json(events);
|
|
});
|
|
|
|
router.get("/heartbeat-runs/:runId/log", async (req, res) => {
|
|
const runId = req.params.runId as string;
|
|
const run = await heartbeat.getRun(runId);
|
|
if (!run) {
|
|
res.status(404).json({ error: "Heartbeat run not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, run.companyId);
|
|
|
|
const offset = Number(req.query.offset ?? 0);
|
|
const limitBytes = Number(req.query.limitBytes ?? 256000);
|
|
const result = await heartbeat.readLog(runId, {
|
|
offset: Number.isFinite(offset) ? offset : 0,
|
|
limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000,
|
|
});
|
|
|
|
res.json(result);
|
|
});
|
|
|
|
return router;
|
|
}
|