mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Agent management: hire endpoint with permission gates and pending_approval status, config revision tracking with rollback, agent duplicate route, permission CRUD. Block pending_approval agents from auth, heartbeat, and assignments. Approvals: revision request/resubmit flow, approval comments CRUD, issue-approval linking, auto-wake agents on approval decisions with context snapshot. Costs: per-agent breakdown, period filtering (month/week/day/all), cost by agent list endpoint. Adapters: agentConfigurationDoc on all adapters, /llms/agent-configuration.txt reflection routes. Inject PAPERCLIP_APPROVAL_ID, PAPERCLIP_APPROVAL_STATUS, PAPERCLIP_LINKED_ISSUE_IDS into adapter environments. Sidebar badges endpoint for pending approval/inbox counts. Dashboard and company settings extensions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
957 lines
30 KiB
TypeScript
957 lines
30 KiB
TypeScript
import { Router, type Request } from "express";
|
|
import type { Db } from "@paperclip/db";
|
|
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db";
|
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
import {
|
|
createAgentKeySchema,
|
|
createAgentHireSchema,
|
|
createAgentSchema,
|
|
updateAgentPermissionsSchema,
|
|
wakeAgentSchema,
|
|
updateAgentSchema,
|
|
} from "@paperclip/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
import {
|
|
agentService,
|
|
approvalService,
|
|
heartbeatService,
|
|
issueApprovalService,
|
|
issueService,
|
|
logActivity,
|
|
} from "../services/index.js";
|
|
import { forbidden } from "../errors.js";
|
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
import { listAdapterModels } from "../adapters/index.js";
|
|
|
|
const SECRET_PAYLOAD_KEY_RE =
|
|
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
|
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
|
|
const REDACTED_EVENT_VALUE = "***REDACTED***";
|
|
|
|
function sanitizeValue(value: unknown): unknown {
|
|
if (value === null || value === undefined) return value;
|
|
if (Array.isArray(value)) return value.map(sanitizeValue);
|
|
if (typeof value !== "object") return value;
|
|
if (value instanceof Date) return value;
|
|
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value;
|
|
return sanitizeRecord(value as Record<string, unknown>);
|
|
}
|
|
|
|
function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
|
|
const redacted: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(record)) {
|
|
const isSensitiveKey = SECRET_PAYLOAD_KEY_RE.test(key);
|
|
if (isSensitiveKey) {
|
|
redacted[key] = REDACTED_EVENT_VALUE;
|
|
continue;
|
|
}
|
|
if (typeof value === "string" && JWT_VALUE_RE.test(value)) {
|
|
redacted[key] = REDACTED_EVENT_VALUE;
|
|
continue;
|
|
}
|
|
redacted[key] = sanitizeValue(value);
|
|
}
|
|
return redacted;
|
|
}
|
|
|
|
function redactEventPayload(payload: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
if (!payload) return null;
|
|
if (Array.isArray(payload) || typeof payload !== "object") {
|
|
return payload as Record<string, unknown>;
|
|
}
|
|
return sanitizeRecord(payload);
|
|
}
|
|
|
|
export function agentRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = agentService(db);
|
|
const approvalsSvc = approvalService(db);
|
|
const heartbeat = heartbeatService(db);
|
|
const issueApprovalsSvc = issueApprovalService(db);
|
|
|
|
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
|
|
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
|
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
|
}
|
|
|
|
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.type === "board") return null;
|
|
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
|
const actorAgent = await svc.getById(req.actor.agentId);
|
|
if (!actorAgent || actorAgent.companyId !== companyId) {
|
|
throw forbidden("Agent key cannot access another company");
|
|
}
|
|
if (!canCreateAgents(actorAgent)) {
|
|
throw forbidden("Missing permission: can create agents");
|
|
}
|
|
return actorAgent;
|
|
}
|
|
|
|
async function assertCanReadConfigurations(req: Request, companyId: string) {
|
|
return assertCanCreateAgentsForCompany(req, companyId);
|
|
}
|
|
|
|
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.type === "board") return true;
|
|
if (!req.actor.agentId) return false;
|
|
const actorAgent = await svc.getById(req.actor.agentId);
|
|
if (!actorAgent || actorAgent.companyId !== companyId) return false;
|
|
return canCreateAgents(actorAgent);
|
|
}
|
|
|
|
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
|
assertCompanyAccess(req, targetAgent.companyId);
|
|
if (req.actor.type === "board") return;
|
|
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
|
|
|
const actorAgent = await svc.getById(req.actor.agentId);
|
|
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
|
|
throw forbidden("Agent key cannot access another company");
|
|
}
|
|
|
|
if (actorAgent.id === targetAgent.id) return;
|
|
if (actorAgent.role === "ceo") return;
|
|
if (canCreateAgents(actorAgent)) return;
|
|
throw forbidden("Only CEO or agent creators can modify other agents");
|
|
}
|
|
|
|
function parseSourceIssueIds(input: {
|
|
sourceIssueId?: string | null;
|
|
sourceIssueIds?: string[];
|
|
}): string[] {
|
|
const values: string[] = [];
|
|
if (Array.isArray(input.sourceIssueIds)) values.push(...input.sourceIssueIds);
|
|
if (typeof input.sourceIssueId === "string" && input.sourceIssueId.length > 0) {
|
|
values.push(input.sourceIssueId);
|
|
}
|
|
return Array.from(new Set(values));
|
|
}
|
|
|
|
function redactForRestrictedAgentView(agent: Awaited<ReturnType<typeof svc.getById>>) {
|
|
if (!agent) return null;
|
|
return {
|
|
...agent,
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
};
|
|
}
|
|
|
|
function redactAgentConfiguration(agent: Awaited<ReturnType<typeof svc.getById>>) {
|
|
if (!agent) return null;
|
|
return {
|
|
id: agent.id,
|
|
companyId: agent.companyId,
|
|
name: agent.name,
|
|
role: agent.role,
|
|
title: agent.title,
|
|
status: agent.status,
|
|
reportsTo: agent.reportsTo,
|
|
adapterType: agent.adapterType,
|
|
adapterConfig: redactEventPayload(agent.adapterConfig),
|
|
runtimeConfig: redactEventPayload(agent.runtimeConfig),
|
|
permissions: agent.permissions,
|
|
updatedAt: agent.updatedAt,
|
|
};
|
|
}
|
|
|
|
function redactRevisionSnapshot(snapshot: unknown): Record<string, unknown> {
|
|
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return {};
|
|
const record = snapshot as Record<string, unknown>;
|
|
return {
|
|
...record,
|
|
adapterConfig: redactEventPayload(
|
|
typeof record.adapterConfig === "object" && record.adapterConfig !== null
|
|
? (record.adapterConfig as Record<string, unknown>)
|
|
: {},
|
|
),
|
|
runtimeConfig: redactEventPayload(
|
|
typeof record.runtimeConfig === "object" && record.runtimeConfig !== null
|
|
? (record.runtimeConfig as Record<string, unknown>)
|
|
: {},
|
|
),
|
|
metadata:
|
|
typeof record.metadata === "object" && record.metadata !== null
|
|
? redactEventPayload(record.metadata as Record<string, unknown>)
|
|
: record.metadata ?? null,
|
|
};
|
|
}
|
|
|
|
function redactConfigRevision(
|
|
revision: Record<string, unknown> & { beforeConfig: unknown; afterConfig: unknown },
|
|
) {
|
|
return {
|
|
...revision,
|
|
beforeConfig: redactRevisionSnapshot(revision.beforeConfig),
|
|
afterConfig: redactRevisionSnapshot(revision.afterConfig),
|
|
};
|
|
}
|
|
|
|
function toLeanOrgNode(node: Record<string, unknown>): Record<string, unknown> {
|
|
const reports = Array.isArray(node.reports)
|
|
? (node.reports as Array<Record<string, unknown>>).map((report) => toLeanOrgNode(report))
|
|
: [];
|
|
return {
|
|
id: String(node.id),
|
|
name: String(node.name),
|
|
role: String(node.role),
|
|
status: String(node.status),
|
|
reports,
|
|
};
|
|
}
|
|
|
|
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);
|
|
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
|
|
if (canReadConfigs || req.actor.type === "board") {
|
|
res.json(result);
|
|
return;
|
|
}
|
|
res.json(result.map((agent) => redactForRestrictedAgentView(agent)));
|
|
});
|
|
|
|
router.get("/companies/:companyId/org", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const tree = await svc.orgForCompany(companyId);
|
|
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
|
|
res.json(leanTree);
|
|
});
|
|
|
|
router.get("/companies/:companyId/agent-configurations", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
await assertCanReadConfigurations(req, companyId);
|
|
const rows = await svc.list(companyId);
|
|
res.json(rows.map((row) => redactAgentConfiguration(row)));
|
|
});
|
|
|
|
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);
|
|
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
|
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
|
|
if (!canRead) {
|
|
const chainOfCommand = await svc.getChainOfCommand(agent.id);
|
|
res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand });
|
|
return;
|
|
}
|
|
}
|
|
const chainOfCommand = await svc.getChainOfCommand(agent.id);
|
|
res.json({ ...agent, chainOfCommand });
|
|
});
|
|
|
|
router.get("/agents/:id/configuration", 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;
|
|
}
|
|
await assertCanReadConfigurations(req, agent.companyId);
|
|
res.json(redactAgentConfiguration(agent));
|
|
});
|
|
|
|
router.get("/agents/:id/config-revisions", 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;
|
|
}
|
|
await assertCanReadConfigurations(req, agent.companyId);
|
|
const revisions = await svc.listConfigRevisions(id);
|
|
res.json(revisions.map((revision) => redactConfigRevision(revision)));
|
|
});
|
|
|
|
router.get("/agents/:id/config-revisions/:revisionId", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const revisionId = req.params.revisionId as string;
|
|
const agent = await svc.getById(id);
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
await assertCanReadConfigurations(req, agent.companyId);
|
|
const revision = await svc.getConfigRevision(id, revisionId);
|
|
if (!revision) {
|
|
res.status(404).json({ error: "Revision not found" });
|
|
return;
|
|
}
|
|
res.json(redactConfigRevision(revision));
|
|
});
|
|
|
|
router.post("/agents/:id/config-revisions/:revisionId/rollback", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const revisionId = req.params.revisionId as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
await assertCanUpdateAgent(req, existing);
|
|
|
|
const actor = getActorInfo(req);
|
|
const updated = await svc.rollbackConfigRevision(id, revisionId, {
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
if (!updated) {
|
|
res.status(404).json({ error: "Revision not found" });
|
|
return;
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId: updated.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "agent.config_rolled_back",
|
|
entityType: "agent",
|
|
entityId: updated.id,
|
|
details: { revisionId },
|
|
});
|
|
|
|
res.json(updated);
|
|
});
|
|
|
|
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/agent-hires", validate(createAgentHireSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
await assertCanCreateAgentsForCompany(req, companyId);
|
|
const sourceIssueIds = parseSourceIssueIds(req.body);
|
|
const { sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body;
|
|
|
|
const company = await db
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!company) {
|
|
res.status(404).json({ error: "Company not found" });
|
|
return;
|
|
}
|
|
|
|
const requiresApproval = company.requireBoardApprovalForNewAgents;
|
|
const status = requiresApproval ? "pending_approval" : "idle";
|
|
const agent = await svc.create(companyId, {
|
|
...hireInput,
|
|
status,
|
|
spentMonthlyCents: 0,
|
|
lastHeartbeatAt: null,
|
|
});
|
|
|
|
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
|
|
const actor = getActorInfo(req);
|
|
|
|
if (requiresApproval) {
|
|
approval = await approvalsSvc.create(companyId, {
|
|
type: "hire_agent",
|
|
requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null,
|
|
requestedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
status: "pending",
|
|
payload: {
|
|
...hireInput,
|
|
agentId: agent.id,
|
|
requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null,
|
|
requestedConfigurationSnapshot: {
|
|
adapterType: hireInput.adapterType ?? agent.adapterType,
|
|
adapterConfig: hireInput.adapterConfig ?? agent.adapterConfig,
|
|
runtimeConfig: hireInput.runtimeConfig ?? agent.runtimeConfig,
|
|
},
|
|
},
|
|
decisionNote: null,
|
|
decidedByUserId: null,
|
|
decidedAt: null,
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
if (sourceIssueIds.length > 0) {
|
|
await issueApprovalsSvc.linkManyForApproval(approval.id, sourceIssueIds, {
|
|
agentId: actor.actorType === "agent" ? actor.actorId : null,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "agent.hire_created",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
details: {
|
|
name: agent.name,
|
|
role: agent.role,
|
|
requiresApproval,
|
|
approvalId: approval?.id ?? null,
|
|
issueIds: sourceIssueIds,
|
|
},
|
|
});
|
|
|
|
if (approval) {
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "approval.created",
|
|
entityType: "approval",
|
|
entityId: approval.id,
|
|
details: { type: approval.type, linkedAgentId: agent.id },
|
|
});
|
|
}
|
|
|
|
res.status(201).json({ agent, approval });
|
|
});
|
|
|
|
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,
|
|
runId: actor.runId,
|
|
action: "agent.created",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
details: { name: agent.name, role: agent.role },
|
|
});
|
|
|
|
res.status(201).json(agent);
|
|
});
|
|
|
|
router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), 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") {
|
|
const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
|
|
if (!actorAgent || actorAgent.companyId !== existing.companyId) {
|
|
res.status(403).json({ error: "Forbidden" });
|
|
return;
|
|
}
|
|
if (actorAgent.role !== "ceo") {
|
|
res.status(403).json({ error: "Only CEO can manage permissions" });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const agent = await svc.updatePermissions(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,
|
|
runId: actor.runId,
|
|
action: "agent.permissions_updated",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
details: req.body,
|
|
});
|
|
|
|
res.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;
|
|
}
|
|
await assertCanUpdateAgent(req, existing);
|
|
|
|
if (Object.prototype.hasOwnProperty.call(req.body, "permissions")) {
|
|
res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const agent = await svc.update(id, req.body, {
|
|
recordRevision: {
|
|
createdByAgentId: actor.agentId,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
source: "patch",
|
|
},
|
|
});
|
|
if (!agent) {
|
|
res.status(404).json({ error: "Agent not found" });
|
|
return;
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId: agent.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
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.delete("/agents/:id", async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const agent = await svc.remove(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.deleted",
|
|
entityType: "agent",
|
|
entityId: agent.id,
|
|
});
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
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,
|
|
runId: actor.runId,
|
|
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,
|
|
runId: actor.runId,
|
|
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);
|
|
const redactedEvents = events.map((event) => ({
|
|
...event,
|
|
payload: redactEventPayload(event.payload),
|
|
}));
|
|
res.json(redactedEvents);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
router.get("/issues/:id/live-runs", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const issueSvc = issueService(db);
|
|
const issue = await issueSvc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
|
|
const liveRuns = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
status: heartbeatRuns.status,
|
|
invocationSource: heartbeatRuns.invocationSource,
|
|
triggerDetail: heartbeatRuns.triggerDetail,
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
agentId: heartbeatRuns.agentId,
|
|
agentName: agentsTable.name,
|
|
adapterType: agentsTable.adapterType,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, issue.companyId),
|
|
inArray(heartbeatRuns.status, ["queued", "running"]),
|
|
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${id}`,
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.createdAt));
|
|
|
|
res.json(liveRuns);
|
|
});
|
|
|
|
router.get("/issues/:id/active-run", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const issueSvc = issueService(db);
|
|
const issue = await issueSvc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
|
|
if (!issue.assigneeAgentId || issue.status !== "in_progress") {
|
|
res.json(null);
|
|
return;
|
|
}
|
|
|
|
const agent = await svc.getById(issue.assigneeAgentId);
|
|
if (!agent) {
|
|
res.json(null);
|
|
return;
|
|
}
|
|
|
|
const run = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
|
|
if (!run) {
|
|
res.json(null);
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
...run,
|
|
agentId: agent.id,
|
|
agentName: agent.name,
|
|
adapterType: agent.adapterType,
|
|
});
|
|
});
|
|
|
|
return router;
|
|
}
|