Implement agent hiring, approval workflows, config revisions, LLM reflection, and sidebar badges

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>
This commit is contained in:
Forgotten
2026-02-19 13:02:41 -06:00
parent db0b19bf9d
commit c09037ffad
28 changed files with 2393 additions and 148 deletions

View File

@@ -1,15 +1,25 @@
import { Router } from "express";
import { Router, type Request } from "express";
import type { Db } from "@paperclip/db";
import { agents as agentsTable, heartbeatRuns } 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, heartbeatService, issueService, logActivity } from "../services/index.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";
@@ -55,7 +65,141 @@ function redactEventPayload(payload: Record<string, unknown> | null): Record<str
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;
@@ -67,14 +211,27 @@ export function agentRoutes(db: Db) {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result);
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);
res.json(tree);
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) => {
@@ -99,10 +256,93 @@ export function agentRoutes(db: Db) {
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;
@@ -141,6 +381,99 @@ export function agentRoutes(db: Db) {
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);
@@ -172,7 +505,7 @@ export function agentRoutes(db: Db) {
res.status(201).json(agent);
});
router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
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) {
@@ -181,18 +514,67 @@ export function agentRoutes(db: Db) {
}
assertCompanyAccess(req, existing.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only modify itself" });
return;
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.update(id, req.body);
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,
@@ -275,6 +657,27 @@ export function agentRoutes(db: Db) {
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;

View File

@@ -1,13 +1,27 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createApprovalSchema, resolveApprovalSchema } from "@paperclip/shared";
import {
addApprovalCommentSchema,
createApprovalSchema,
requestApprovalRevisionSchema,
resolveApprovalSchema,
resubmitApprovalSchema,
} from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { approvalService, logActivity } from "../services/index.js";
import { logger } from "../middleware/logger.js";
import {
approvalService,
heartbeatService,
issueApprovalService,
logActivity,
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function approvalRoutes(db: Db) {
const router = Router();
const svc = approvalService(db);
const heartbeat = heartbeatService(db);
const issueApprovalsSvc = issueApprovalService(db);
router.get("/companies/:companyId/approvals", async (req, res) => {
const companyId = req.params.companyId as string;
@@ -17,16 +31,33 @@ export function approvalRoutes(db: Db) {
res.json(result);
});
router.get("/approvals/:id", async (req, res) => {
const id = req.params.id as string;
const approval = await svc.getById(id);
if (!approval) {
res.status(404).json({ error: "Approval not found" });
return;
}
assertCompanyAccess(req, approval.companyId);
res.json(approval);
});
router.post("/companies/:companyId/approvals", validate(createApprovalSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rawIssueIds = req.body.issueIds;
const issueIds = Array.isArray(rawIssueIds)
? rawIssueIds.filter((value: unknown): value is string => typeof value === "string")
: [];
const uniqueIssueIds = Array.from(new Set(issueIds));
const { issueIds: _issueIds, ...approvalInput } = req.body;
const actor = getActorInfo(req);
const approval = await svc.create(companyId, {
...req.body,
...approvalInput,
requestedByUserId: actor.actorType === "user" ? actor.actorId : null,
requestedByAgentId:
req.body.requestedByAgentId ?? (actor.actorType === "agent" ? actor.actorId : null),
approvalInput.requestedByAgentId ?? (actor.actorType === "agent" ? actor.actorId : null),
status: "pending",
decisionNote: null,
decidedByUserId: null,
@@ -34,6 +65,13 @@ export function approvalRoutes(db: Db) {
updatedAt: new Date(),
});
if (uniqueIssueIds.length > 0) {
await issueApprovalsSvc.linkManyForApproval(approval.id, uniqueIssueIds, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
}
await logActivity(db, {
companyId,
actorType: actor.actorType,
@@ -42,16 +80,31 @@ export function approvalRoutes(db: Db) {
action: "approval.created",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
details: { type: approval.type, issueIds: uniqueIssueIds },
});
res.status(201).json(approval);
});
router.get("/approvals/:id/issues", async (req, res) => {
const id = req.params.id as string;
const approval = await svc.getById(id);
if (!approval) {
res.status(404).json({ error: "Approval not found" });
return;
}
assertCompanyAccess(req, approval.companyId);
const issues = await issueApprovalsSvc.listIssuesForApproval(id);
res.json(issues);
});
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
await logActivity(db, {
companyId: approval.companyId,
@@ -60,9 +113,76 @@ export function approvalRoutes(db: Db) {
action: "approval.approved",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
details: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
},
});
if (approval.requestedByAgentId) {
try {
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_approved",
payload: {
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
},
requestedByActorType: "user",
requestedByActorId: req.actor.userId ?? "board",
contextSnapshot: {
source: "approval.approved",
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
taskId: primaryIssueId,
wakeReason: "approval_approved",
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_queued",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
wakeRunId: wakeRun?.id ?? null,
linkedIssueIds,
},
});
} catch (err) {
logger.warn(
{
err,
approvalId: approval.id,
requestedByAgentId: approval.requestedByAgentId,
},
"failed to queue requester wakeup after approval",
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_failed",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
linkedIssueIds,
error: err instanceof Error ? err.message : String(err),
},
});
}
}
res.json(approval);
});
@@ -84,5 +204,100 @@ export function approvalRoutes(db: Db) {
res.json(approval);
});
router.post(
"/approvals/:id/request-revision",
validate(requestApprovalRevisionSchema),
async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.requestRevision(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.revision_requested",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
res.json(approval);
},
);
router.post("/approvals/:id/resubmit", validate(resubmitApprovalSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Approval not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== existing.requestedByAgentId) {
res.status(403).json({ error: "Only requesting agent can resubmit this approval" });
return;
}
const approval = await svc.resubmit(id, req.body.payload);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: approval.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "approval.resubmitted",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
res.json(approval);
});
router.get("/approvals/:id/comments", async (req, res) => {
const id = req.params.id as string;
const approval = await svc.getById(id);
if (!approval) {
res.status(404).json({ error: "Approval not found" });
return;
}
assertCompanyAccess(req, approval.companyId);
const comments = await svc.listComments(id);
res.json(comments);
});
router.post("/approvals/:id/comments", validate(addApprovalCommentSchema), async (req, res) => {
const id = req.params.id as string;
const approval = await svc.getById(id);
if (!approval) {
res.status(404).json({ error: "Approval not found" });
return;
}
assertCompanyAccess(req, approval.companyId);
const actor = getActorInfo(req);
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
});
await logActivity(db, {
companyId: approval.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "approval.comment_added",
entityType: "approval",
entityId: approval.id,
details: { commentId: comment.id },
});
res.status(201).json(comment);
});
return router;
}

View File

@@ -40,24 +40,33 @@ export function costRoutes(db: Db) {
res.status(201).json(event);
});
function parseDateRange(query: Record<string, unknown>) {
const from = query.from ? new Date(query.from as string) : undefined;
const to = query.to ? new Date(query.to as string) : undefined;
return (from || to) ? { from, to } : undefined;
}
router.get("/companies/:companyId/costs/summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const summary = await costs.summary(companyId);
const range = parseDateRange(req.query);
const summary = await costs.summary(companyId, range);
res.json(summary);
});
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rows = await costs.byAgent(companyId);
const range = parseDateRange(req.query);
const rows = await costs.byAgent(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rows = await costs.byProject(companyId);
const range = parseDateRange(req.query);
const rows = await costs.byProject(companyId, range);
res.json(rows);
});

View File

@@ -8,3 +8,5 @@ export { approvalRoutes } from "./approvals.js";
export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { llmRoutes } from "./llms.js";

View File

@@ -1,13 +1,20 @@
import { Router } from "express";
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclip/db";
import {
addIssueCommentSchema,
checkoutIssueSchema,
createIssueSchema,
linkIssueApprovalSchema,
updateIssueSchema,
} from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { heartbeatService, issueService, logActivity } from "../services/index.js";
import {
agentService,
heartbeatService,
issueApprovalService,
issueService,
logActivity,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
@@ -15,6 +22,25 @@ export function issueRoutes(db: Db) {
const router = Router();
const svc = issueService(db);
const heartbeat = heartbeatService(db);
const agentsSvc = agentService(db);
const issueApprovalsSvc = issueApprovalService(db);
async function assertCanManageIssueApprovalLinks(req: Request, res: Response, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return true;
if (!req.actor.agentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
const actorAgent = await agentsSvc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
res.status(403).json({ error: "Forbidden" });
return false;
}
if (actorAgent.role === "ceo" || Boolean(actorAgent.permissions?.canCreateAgents)) return true;
res.status(403).json({ error: "Missing permission to link approvals" });
return false;
}
router.get("/companies/:companyId/issues", async (req, res) => {
const companyId = req.params.companyId as string;
@@ -40,6 +66,77 @@ export function issueRoutes(db: Db) {
res.json({ ...issue, ancestors });
});
router.get("/issues/:id/approvals", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
res.json(approvals);
});
router.post("/issues/:id/approvals", validate(linkIssueApprovalSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
const actor = getActorInfo(req);
await issueApprovalsSvc.link(id, req.body.approvalId, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.approval_linked",
entityType: "issue",
entityId: issue.id,
details: { approvalId: req.body.approvalId },
});
const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
res.status(201).json(approvals);
});
router.delete("/issues/:id/approvals/:approvalId", async (req, res) => {
const id = req.params.id as string;
const approvalId = req.params.approvalId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
await issueApprovalsSvc.unlink(id, approvalId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.approval_unlinked",
entityType: "issue",
entityId: issue.id,
details: { approvalId },
});
res.json({ ok: true });
});
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -96,6 +193,14 @@ export function issueRoutes(db: Db) {
return;
}
// Build activity details with previous values for changed fields
const previous: Record<string, unknown> = {};
for (const key of Object.keys(updateFields)) {
if (key in existing && (existing as Record<string, unknown>)[key] !== (updateFields as Record<string, unknown>)[key]) {
previous[key] = (existing as Record<string, unknown>)[key];
}
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
@@ -106,7 +211,7 @@ export function issueRoutes(db: Db) {
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: updateFields,
details: { ...updateFields, _previous: Object.keys(previous).length > 0 ? previous : undefined },
});
let comment = null;
@@ -383,6 +488,28 @@ export function issueRoutes(db: Db) {
},
})
.catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue reopen comment"));
} else if (currentIssue.assigneeAgentId) {
void heartbeat
.wakeup(currentIssue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: currentIssue.id,
commentId: comment.id,
mutation: "comment",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: currentIssue.id,
taskId: currentIssue.id,
commentId: comment.id,
source: "issue.comment",
wakeReason: "issue_commented",
},
})
.catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue comment"));
}
res.status(201).json(comment);

66
server/src/routes/llms.ts Normal file
View File

@@ -0,0 +1,66 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclip/db";
import { forbidden } from "../errors.js";
import { listServerAdapters } from "../adapters/index.js";
import { agentService } from "../services/agents.js";
function hasCreatePermission(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);
}
export function llmRoutes(db: Db) {
const router = Router();
const agentsSvc = agentService(db);
async function assertCanRead(req: Request) {
if (req.actor.type === "board") return;
if (req.actor.type !== "agent" || !req.actor.agentId) {
throw forbidden("Board or permitted agent authentication required");
}
const actorAgent = await agentsSvc.getById(req.actor.agentId);
if (!actorAgent || !hasCreatePermission(actorAgent)) {
throw forbidden("Missing permission to read agent configuration reflection");
}
}
router.get("/llms/agent-configuration.txt", async (req, res) => {
await assertCanRead(req);
const adapters = listServerAdapters().sort((a, b) => a.type.localeCompare(b.type));
const lines = [
"# Paperclip Agent Configuration Index",
"",
"Installed adapters:",
...adapters.map((adapter) => `- ${adapter.type}: /llms/agent-configuration/${adapter.type}.txt`),
"",
"Related API endpoints:",
"- GET /api/companies/:companyId/agent-configurations",
"- GET /api/agents/:id/configuration",
"- POST /api/companies/:companyId/agent-hires",
"",
"Notes:",
"- Sensitive values are redacted in configuration read APIs.",
"- New hires may be created in pending_approval state depending on company settings.",
"",
];
res.type("text/plain").send(lines.join("\n"));
});
router.get("/llms/agent-configuration/:adapterType.txt", async (req, res) => {
await assertCanRead(req);
const adapterType = req.params.adapterType as string;
const adapter = listServerAdapters().find((entry) => entry.type === adapterType);
if (!adapter) {
res.status(404).type("text/plain").send(`Unknown adapter type: ${adapterType}`);
return;
}
res
.type("text/plain")
.send(
adapter.agentConfigurationDoc ??
`# ${adapterType} agent configuration\n\nNo adapter-specific documentation registered.`,
);
});
return router;
}

View File

@@ -0,0 +1,18 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { assertCompanyAccess } from "./authz.js";
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const badges = await svc.get(companyId);
res.json(badges);
});
return router;
}