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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
66
server/src/routes/llms.ts
Normal 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;
|
||||
}
|
||||
18
server/src/routes/sidebar-badges.ts
Normal file
18
server/src/routes/sidebar-badges.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user