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; }