Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New services: companies, approvals, costs, dashboard, heartbeat, activity-log. Add auth middleware and structured error handling. Expand existing agent and issue routes with richer CRUD operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,52 +1,258 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { createAgentSchema, updateAgentSchema } from "@paperclip/shared";
|
||||
import {
|
||||
createAgentKeySchema,
|
||||
createAgentSchema,
|
||||
updateAgentSchema,
|
||||
} from "@paperclip/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { agentService, heartbeatService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = agentService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
const result = await svc.list();
|
||||
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("/:id", async (req, res) => {
|
||||
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/: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);
|
||||
res.json(agent);
|
||||
});
|
||||
|
||||
router.post("/", validate(createAgentSchema), async (req, res) => {
|
||||
const agent = await svc.create(req.body);
|
||||
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("/:id", validate(updateAgentSchema), async (req, res) => {
|
||||
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.delete("/:id", async (req, res) => {
|
||||
router.post("/agents/:id/pause", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.remove(id);
|
||||
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.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.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, "manual", {
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user