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

@@ -4,7 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
"dev": "tsx src/index.ts",
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"

View File

@@ -5,4 +5,15 @@ export const httpAdapter: ServerAdapterModule = {
type: "http",
execute,
models: [],
agentConfigurationDoc: `# http agent configuration
Adapter: http
Core fields:
- url (string, required): endpoint to invoke
- method (string, optional): HTTP method, default POST
- headers (object, optional): request headers
- payloadTemplate (object, optional): JSON payload template
- timeoutSec (number, optional): request timeout in seconds
`,
};

View File

@@ -1,4 +1,4 @@
export { getServerAdapter, listAdapterModels } from "./registry.js";
export { getServerAdapter, listAdapterModels, listServerAdapters } from "./registry.js";
export type {
ServerAdapterModule,
AdapterExecutionContext,

View File

@@ -5,4 +5,18 @@ export const processAdapter: ServerAdapterModule = {
type: "process",
execute,
models: [],
agentConfigurationDoc: `# process agent configuration
Adapter: process
Core fields:
- command (string, required): command to execute
- args (string[] | string, optional): command arguments
- cwd (string, optional): absolute working directory
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
`,
};

View File

@@ -1,8 +1,8 @@
import type { ServerAdapterModule } from "./types.js";
import { execute as claudeExecute } from "@paperclip/adapter-claude-local/server";
import { models as claudeModels } from "@paperclip/adapter-claude-local";
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclip/adapter-claude-local";
import { execute as codexExecute } from "@paperclip/adapter-codex-local/server";
import { models as codexModels } from "@paperclip/adapter-codex-local";
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclip/adapter-codex-local";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
@@ -11,6 +11,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
execute: claudeExecute,
models: claudeModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: claudeAgentConfigurationDoc,
};
const codexLocalAdapter: ServerAdapterModule = {
@@ -18,6 +19,7 @@ const codexLocalAdapter: ServerAdapterModule = {
execute: codexExecute,
models: codexModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: codexAgentConfigurationDoc,
};
const adaptersByType = new Map<string, ServerAdapterModule>(
@@ -36,3 +38,7 @@ export function getServerAdapter(type: string): ServerAdapterModule {
export function listAdapterModels(type: string): { id: string; label: string }[] {
return adaptersByType.get(type)?.models ?? [];
}
export function listServerAdapters(): ServerAdapterModule[] {
return Array.from(adaptersByType.values());
}

View File

@@ -15,6 +15,8 @@ import { approvalRoutes } from "./routes/approvals.js";
import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { llmRoutes } from "./routes/llms.js";
type UiMode = "none" | "static" | "vite-dev";
@@ -24,6 +26,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode }) {
app.use(express.json());
app.use(httpLogger);
app.use(actorMiddleware(db));
app.use(llmRoutes(db));
// Mount API routes
const api = Router();
@@ -37,6 +40,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode }) {
api.use(costRoutes(db));
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db));
app.use("/api", api);
const __dirname = path.dirname(fileURLToPath(import.meta.url));

View File

@@ -8,6 +8,7 @@ import {
ensurePostgresDatabase,
inspectMigrations,
applyPendingMigrations,
reconcilePendingMigrationHistory,
} from "@paperclip/db";
import detectPort from "detect-port";
import { createApp } from "./app.js";
@@ -48,6 +49,7 @@ function formatPendingMigrationSummary(migrations: string[]): string {
}
async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false;
if (!stdin.isTTY || !stdout.isTTY) return true;
if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true;
@@ -63,7 +65,18 @@ async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
}
async function ensureMigrations(connectionString: string, label: string): Promise<MigrationSummary> {
const state = await inspectMigrations(connectionString);
let state = await inspectMigrations(connectionString);
if (state.status === "needsMigrations" && state.reason === "pending-migrations") {
const repair = await reconcilePendingMigrationHistory(connectionString);
if (repair.repairedMigrations.length > 0) {
logger.warn(
{ repairedMigrations: repair.repairedMigrations },
`${label} had drifted migration history; repaired migration journal entries from existing schema state.`,
);
state = await inspectMigrations(connectionString);
if (state.status === "upToDate") return "already applied";
}
}
if (state.status === "upToDate") return "already applied";
if (state.status === "needsMigrations" && state.reason === "no-migration-journal-non-empty-db") {
logger.warn(

View File

@@ -53,7 +53,7 @@ export function actorMiddleware(db: Db): RequestHandler {
return;
}
if (agentRecord.status === "terminated") {
if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
next();
return;
}
@@ -74,6 +74,17 @@ export function actorMiddleware(db: Db): RequestHandler {
.set({ lastUsedAt: new Date() })
.where(eq(agentApiKeys.id, key.id));
const agentRecord = await db
.select()
.from(agents)
.where(eq(agents.id, key.agentId))
.then((rows) => rows[0] ?? null);
if (!agentRecord || agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
next();
return;
}
req.actor = {
type: "agent",
agentId: key.agentId,

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

View File

@@ -1,6 +1,6 @@
export interface NormalizedAgentPermissions {
export type NormalizedAgentPermissions = Record<string, unknown> & {
canCreateAgents: boolean;
}
};
export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions {
return {

View File

@@ -1,8 +1,17 @@
import { createHash, randomBytes } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, agentApiKeys, heartbeatRuns } from "@paperclip/db";
import {
agents,
agentConfigRevisions,
agentApiKeys,
agentRuntimeState,
agentWakeupRequests,
heartbeatRunEvents,
heartbeatRuns,
} from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
@@ -12,13 +21,118 @@ function createToken() {
return `pcp_${randomBytes(24).toString("hex")}`;
}
const CONFIG_REVISION_FIELDS = [
"name",
"role",
"title",
"reportsTo",
"capabilities",
"adapterType",
"adapterConfig",
"runtimeConfig",
"budgetMonthlyCents",
"metadata",
] as const;
type ConfigRevisionField = (typeof CONFIG_REVISION_FIELDS)[number];
type AgentConfigSnapshot = Pick<typeof agents.$inferSelect, ConfigRevisionField>;
interface RevisionMetadata {
createdByAgentId?: string | null;
createdByUserId?: string | null;
source?: string;
rolledBackFromRevisionId?: string | null;
}
interface UpdateAgentOptions {
recordRevision?: RevisionMetadata;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function jsonEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function buildConfigSnapshot(
row: Pick<typeof agents.$inferSelect, ConfigRevisionField>,
): AgentConfigSnapshot {
return {
name: row.name,
role: row.role,
title: row.title,
reportsTo: row.reportsTo,
capabilities: row.capabilities,
adapterType: row.adapterType,
adapterConfig: row.adapterConfig ?? {},
runtimeConfig: row.runtimeConfig ?? {},
budgetMonthlyCents: row.budgetMonthlyCents,
metadata: row.metadata ?? null,
};
}
function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) {
return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field));
}
function diffConfigSnapshot(
before: AgentConfigSnapshot,
after: AgentConfigSnapshot,
): string[] {
return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field]));
}
function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$inferInsert> {
if (!isPlainRecord(snapshot)) throw unprocessable("Invalid revision snapshot");
if (typeof snapshot.name !== "string" || snapshot.name.length === 0) {
throw unprocessable("Invalid revision snapshot: name");
}
if (typeof snapshot.role !== "string" || snapshot.role.length === 0) {
throw unprocessable("Invalid revision snapshot: role");
}
if (typeof snapshot.adapterType !== "string" || snapshot.adapterType.length === 0) {
throw unprocessable("Invalid revision snapshot: adapterType");
}
if (typeof snapshot.budgetMonthlyCents !== "number" || !Number.isFinite(snapshot.budgetMonthlyCents)) {
throw unprocessable("Invalid revision snapshot: budgetMonthlyCents");
}
return {
name: snapshot.name,
role: snapshot.role,
title: typeof snapshot.title === "string" || snapshot.title === null ? snapshot.title : null,
reportsTo:
typeof snapshot.reportsTo === "string" || snapshot.reportsTo === null ? snapshot.reportsTo : null,
capabilities:
typeof snapshot.capabilities === "string" || snapshot.capabilities === null
? snapshot.capabilities
: null,
adapterType: snapshot.adapterType,
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
};
}
export function agentService(db: Db) {
function normalizeAgentRow(row: typeof agents.$inferSelect) {
return {
...row,
permissions: normalizeAgentPermissions(row.permissions, row.role),
};
}
async function getById(id: string) {
return db
const row = await db
.select()
.from(agents)
.where(eq(agents.id, id))
.then((rows) => rows[0] ?? null);
return row ? normalizeAgentRow(row) : null;
}
async function ensureManager(companyId: string, managerId: string) {
@@ -42,9 +156,76 @@ export function agentService(db: Db) {
}
}
async function updateAgent(
id: string,
data: Partial<typeof agents.$inferInsert>,
options?: UpdateAgentOptions,
) {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated" && data.status && data.status !== "terminated") {
throw conflict("Terminated agents cannot be resumed");
}
if (
existing.status === "pending_approval" &&
data.status &&
data.status !== "pending_approval" &&
data.status !== "terminated"
) {
throw conflict("Pending approval agents cannot be activated directly");
}
if (data.reportsTo !== undefined) {
if (data.reportsTo) {
await ensureManager(existing.companyId, data.reportsTo);
}
await assertNoCycle(id, data.reportsTo);
}
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
if (data.permissions !== undefined) {
const role = (data.role ?? existing.role) as string;
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role);
}
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
const updated = await db
.update(agents)
.set({ ...normalizedPatch, updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
const normalizedUpdated = updated ? normalizeAgentRow(updated) : null;
if (normalizedUpdated && shouldRecordRevision && beforeConfig) {
const afterConfig = buildConfigSnapshot(normalizedUpdated);
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
if (changedKeys.length > 0) {
await db.insert(agentConfigRevisions).values({
companyId: normalizedUpdated.companyId,
agentId: normalizedUpdated.id,
createdByAgentId: options?.recordRevision?.createdByAgentId ?? null,
createdByUserId: options?.recordRevision?.createdByUserId ?? null,
source: options?.recordRevision?.source ?? "patch",
rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null,
changedKeys,
beforeConfig: beforeConfig as unknown as Record<string, unknown>,
afterConfig: afterConfig as unknown as Record<string, unknown>,
});
}
}
return normalizedUpdated;
}
return {
list: (companyId: string) =>
db.select().from(agents).where(eq(agents.companyId, companyId)),
list: async (companyId: string) => {
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
return rows.map(normalizeAgentRow);
},
getById,
@@ -53,62 +234,48 @@ export function agentService(db: Db) {
await ensureManager(companyId, data.reportsTo);
}
const role = data.role ?? "general";
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
const created = await db
.insert(agents)
.values({ ...data, companyId })
.values({ ...data, companyId, role, permissions: normalizedPermissions })
.returning()
.then((rows) => rows[0]);
return created;
return normalizeAgentRow(created);
},
update: async (id: string, data: Partial<typeof agents.$inferInsert>) => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated" && data.status && data.status !== "terminated") {
throw conflict("Terminated agents cannot be resumed");
}
if (data.reportsTo !== undefined) {
if (data.reportsTo) {
await ensureManager(existing.companyId, data.reportsTo);
}
await assertNoCycle(id, data.reportsTo);
}
return db
.update(agents)
.set({ ...data, updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
},
update: updateAgent,
pause: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated") throw conflict("Cannot pause terminated agent");
return db
const updated = await db
.update(agents)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? normalizeAgentRow(updated) : null;
},
resume: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated") throw conflict("Cannot resume terminated agent");
if (existing.status === "pending_approval") {
throw conflict("Pending approval agents cannot be resumed");
}
return db
const updated = await db
.update(agents)
.set({ status: "idle", updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? normalizeAgentRow(updated) : null;
},
terminate: async (id: string) => {
@@ -128,9 +295,104 @@ export function agentService(db: Db) {
return getById(id);
},
remove: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
return db.transaction(async (tx) => {
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.agentId, id));
const deleted = await tx
.delete(agents)
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return deleted ? normalizeAgentRow(deleted) : null;
});
},
activatePendingApproval: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status !== "pending_approval") return existing;
const updated = await db
.update(agents)
.set({ status: "idle", updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? normalizeAgentRow(updated) : null;
},
updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => {
const existing = await getById(id);
if (!existing) return null;
const updated = await db
.update(agents)
.set({
permissions: normalizeAgentPermissions(permissions, existing.role),
updatedAt: new Date(),
})
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? normalizeAgentRow(updated) : null;
},
listConfigRevisions: async (id: string) =>
db
.select()
.from(agentConfigRevisions)
.where(eq(agentConfigRevisions.agentId, id))
.orderBy(desc(agentConfigRevisions.createdAt)),
getConfigRevision: async (id: string, revisionId: string) =>
db
.select()
.from(agentConfigRevisions)
.where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId)))
.then((rows) => rows[0] ?? null),
rollbackConfigRevision: async (
id: string,
revisionId: string,
actor: { agentId?: string | null; userId?: string | null },
) => {
const revision = await db
.select()
.from(agentConfigRevisions)
.where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId)))
.then((rows) => rows[0] ?? null);
if (!revision) return null;
const patch = configPatchFromSnapshot(revision.afterConfig);
return updateAgent(id, patch, {
recordRevision: {
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
source: "rollback",
rolledBackFromRevisionId: revision.id,
},
});
},
createApiKey: async (id: string, name: string) => {
const existing = await getById(id);
if (!existing) throw notFound("Agent not found");
if (existing.status === "pending_approval") {
throw conflict("Cannot create keys for pending approval agents");
}
if (existing.status === "terminated") {
throw conflict("Cannot create keys for terminated agents");
}
const token = createToken();
const keyHash = hashToken(token);
@@ -175,8 +437,9 @@ export function agentService(db: Db) {
orgForCompany: async (companyId: string) => {
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
const byManager = new Map<string | null, typeof rows>();
for (const row of rows) {
const normalizedRows = rows.map(normalizeAgentRow);
const byManager = new Map<string | null, typeof normalizedRows>();
for (const row of normalizedRows) {
const key = row.reportsTo ?? null;
const group = byManager.get(key) ?? [];
group.push(row);

View File

@@ -1,11 +1,22 @@
import { and, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { approvals } from "@paperclip/db";
import { approvalComments, approvals } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
import { agentService } from "./agents.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
async function getExistingApproval(id: string) {
const existing = await db
.select()
.from(approvals)
.where(eq(approvals.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) throw notFound("Approval not found");
return existing;
}
return {
list: (companyId: string, status?: string) => {
@@ -29,15 +40,9 @@ export function approvalService(db: Db) {
.then((rows) => rows[0]),
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await db
.select()
.from(approvals)
.where(eq(approvals.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) throw notFound("Approval not found");
if (existing.status !== "pending") {
throw unprocessable("Only pending approvals can be approved");
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
throw unprocessable("Only pending or revision requested approvals can be approved");
}
const now = new Date();
@@ -56,46 +61,46 @@ export function approvalService(db: Db) {
if (updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
await agentsSvc.create(updated.companyId, {
name: String(payload.name ?? "New Agent"),
role: String(payload.role ?? "general"),
title: typeof payload.title === "string" ? payload.title : null,
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
adapterType: String(payload.adapterType ?? "process"),
adapterConfig:
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
? (payload.adapterConfig as Record<string, unknown>)
: {},
budgetMonthlyCents:
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
metadata:
typeof payload.metadata === "object" && payload.metadata !== null
? (payload.metadata as Record<string, unknown>)
: null,
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
await agentsSvc.activatePendingApproval(payloadAgentId);
} else {
await agentsSvc.create(updated.companyId, {
name: String(payload.name ?? "New Agent"),
role: String(payload.role ?? "general"),
title: typeof payload.title === "string" ? payload.title : null,
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
adapterType: String(payload.adapterType ?? "process"),
adapterConfig:
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
? (payload.adapterConfig as Record<string, unknown>)
: {},
budgetMonthlyCents:
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
metadata:
typeof payload.metadata === "object" && payload.metadata !== null
? (payload.metadata as Record<string, unknown>)
: null,
status: "idle",
spentMonthlyCents: 0,
permissions: undefined,
lastHeartbeatAt: null,
});
}
}
return updated;
},
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await db
.select()
.from(approvals)
.where(eq(approvals.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) throw notFound("Approval not found");
if (existing.status !== "pending") {
throw unprocessable("Only pending approvals can be rejected");
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
throw unprocessable("Only pending or revision requested approvals can be rejected");
}
const now = new Date();
return db
const updated = await db
.update(approvals)
.set({
status: "rejected",
@@ -107,6 +112,92 @@ export function approvalService(db: Db) {
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
if (updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
await agentsSvc.terminate(payloadAgentId);
}
}
return updated;
},
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await getExistingApproval(id);
if (existing.status !== "pending") {
throw unprocessable("Only pending approvals can request revision");
}
const now = new Date();
return db
.update(approvals)
.set({
status: "revision_requested",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
},
resubmit: async (id: string, payload?: Record<string, unknown>) => {
const existing = await getExistingApproval(id);
if (existing.status !== "revision_requested") {
throw unprocessable("Only revision requested approvals can be resubmitted");
}
const now = new Date();
return db
.update(approvals)
.set({
status: "pending",
payload: payload ?? existing.payload,
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
},
listComments: async (approvalId: string) => {
const existing = await getExistingApproval(approvalId);
return db
.select()
.from(approvalComments)
.where(
and(
eq(approvalComments.approvalId, approvalId),
eq(approvalComments.companyId, existing.companyId),
),
)
.orderBy(asc(approvalComments.createdAt));
},
addComment: async (
approvalId: string,
body: string,
actor: { agentId?: string; userId?: string },
) => {
const existing = await getExistingApproval(approvalId);
return db
.insert(approvalComments)
.values({
companyId: existing.companyId,
approvalId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
body,
})
.returning()
.then((rows) => rows[0]);
},
};
}

View File

@@ -13,6 +13,7 @@ import {
heartbeatRuns,
heartbeatRunEvents,
costEvents,
approvalComments,
approvals,
activityLog,
} from "@paperclip/db";
@@ -61,6 +62,7 @@ export function companyService(db: Db) {
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await tx.delete(approvals).where(eq(approvals.companyId, id));
await tx.delete(issues).where(eq(issues.companyId, id));
await tx.delete(goals).where(eq(goals.companyId, id));

View File

@@ -1,8 +1,13 @@
import { and, desc, eq, isNotNull, sql } from "drizzle-orm";
import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, companies, costEvents } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
export interface CostDateRange {
from?: Date;
to?: Date;
}
export function costService(db: Db) {
return {
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
@@ -61,7 +66,7 @@ export function costService(db: Db) {
return event;
},
summary: async (companyId: string) => {
summary: async (companyId: string, range?: CostDateRange) => {
const company = await db
.select()
.from(companies)
@@ -70,43 +75,71 @@ export function costService(db: Db) {
if (!company) throw notFound("Company not found");
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
const [{ total }] = await db
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
})
.from(costEvents)
.where(and(...conditions));
const spendCents = Number(total);
const utilization =
company.budgetMonthlyCents > 0
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
? (spendCents / company.budgetMonthlyCents) * 100
: 0;
return {
companyId,
monthSpendCents: company.spentMonthlyCents,
monthBudgetCents: company.budgetMonthlyCents,
monthUtilizationPercent: Number(utilization.toFixed(2)),
spendCents,
budgetCents: company.budgetMonthlyCents,
utilizationPercent: Number(utilization.toFixed(2)),
};
},
byAgent: async (companyId: string) =>
db
byAgent: async (companyId: string, range?: CostDateRange) => {
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
return db
.select({
agentId: costEvents.agentId,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)`,
agentName: agents.name,
agentStatus: agents.status,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
})
.from(costEvents)
.where(eq(costEvents.companyId, companyId))
.groupBy(costEvents.agentId)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
.leftJoin(agents, eq(costEvents.agentId, agents.id))
.where(and(...conditions))
.groupBy(costEvents.agentId, agents.name, agents.status)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
},
byProject: async (companyId: string) =>
db
byProject: async (companyId: string, range?: CostDateRange) => {
const conditions: ReturnType<typeof eq>[] = [
eq(costEvents.companyId, companyId),
isNotNull(costEvents.projectId),
];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
return db
.select({
projectId: costEvents.projectId,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)`,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
})
.from(costEvents)
.where(and(eq(costEvents.companyId, companyId), isNotNull(costEvents.projectId)))
.where(and(...conditions))
.groupBy(costEvents.projectId)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
},
};
}

View File

@@ -1,6 +1,6 @@
import { and, eq, sql } from "drizzle-orm";
import { and, eq, gte, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, approvals, companies, issues } from "@paperclip/db";
import { agents, approvals, companies, costEvents, issues } from "@paperclip/db";
import { notFound } from "../errors.js";
export function dashboardService(db: Db) {
@@ -69,9 +69,24 @@ export function dashboardService(db: Db) {
if (row.status !== "done" && row.status !== "cancelled") taskCounts.open += count;
}
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const [{ monthSpend }] = await db
.select({
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
})
.from(costEvents)
.where(
and(
eq(costEvents.companyId, companyId),
gte(costEvents.occurredAt, monthStart),
),
);
const monthSpendCents = Number(monthSpend);
const utilization =
company.budgetMonthlyCents > 0
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
? (monthSpendCents / company.budgetMonthlyCents) * 100
: 0;
return {
@@ -84,7 +99,7 @@ export function dashboardService(db: Db) {
},
tasks: taskCounts,
costs: {
monthSpendCents: company.spentMonthlyCents,
monthSpendCents,
monthBudgetCents: company.budgetMonthlyCents,
monthUtilizationPercent: Number(utilization.toFixed(2)),
},

View File

@@ -621,7 +621,11 @@ export function heartbeatService(db: Db) {
const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
if (agent.status === "paused" || agent.status === "terminated") {
if (
agent.status === "paused" ||
agent.status === "terminated" ||
agent.status === "pending_approval"
) {
throw conflict("Agent is not invokable in its current state", { status: agent.status });
}

View File

@@ -2,11 +2,13 @@ export { companyService } from "./companies.js";
export { agentService } from "./agents.js";
export { projectService } from "./projects.js";
export { issueService, type IssueFilters } from "./issues.js";
export { issueApprovalService } from "./issue-approvals.js";
export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";
export { costService } from "./costs.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";

View File

@@ -0,0 +1,169 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { approvals, issueApprovals, issues } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
interface LinkActor {
agentId?: string | null;
userId?: string | null;
}
export function issueApprovalService(db: Db) {
async function getIssue(issueId: string) {
return db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
}
async function getApproval(approvalId: string) {
return db
.select()
.from(approvals)
.where(eq(approvals.id, approvalId))
.then((rows) => rows[0] ?? null);
}
async function assertIssueAndApprovalSameCompany(issueId: string, approvalId: string) {
const issue = await getIssue(issueId);
if (!issue) throw notFound("Issue not found");
const approval = await getApproval(approvalId);
if (!approval) throw notFound("Approval not found");
if (issue.companyId !== approval.companyId) {
throw unprocessable("Issue and approval must belong to the same company");
}
return { issue, approval };
}
return {
listApprovalsForIssue: async (issueId: string) => {
const issue = await getIssue(issueId);
if (!issue) throw notFound("Issue not found");
return db
.select({
id: approvals.id,
companyId: approvals.companyId,
type: approvals.type,
requestedByAgentId: approvals.requestedByAgentId,
requestedByUserId: approvals.requestedByUserId,
status: approvals.status,
payload: approvals.payload,
decisionNote: approvals.decisionNote,
decidedByUserId: approvals.decidedByUserId,
decidedAt: approvals.decidedAt,
createdAt: approvals.createdAt,
updatedAt: approvals.updatedAt,
})
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(eq(issueApprovals.issueId, issueId))
.orderBy(desc(issueApprovals.createdAt));
},
listIssuesForApproval: async (approvalId: string) => {
const approval = await getApproval(approvalId);
if (!approval) throw notFound("Approval not found");
return db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
goalId: issues.goalId,
parentId: issues.parentId,
title: issues.title,
description: issues.description,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
createdByAgentId: issues.createdByAgentId,
createdByUserId: issues.createdByUserId,
issueNumber: issues.issueNumber,
identifier: issues.identifier,
requestDepth: issues.requestDepth,
billingCode: issues.billingCode,
startedAt: issues.startedAt,
completedAt: issues.completedAt,
cancelledAt: issues.cancelledAt,
createdAt: issues.createdAt,
updatedAt: issues.updatedAt,
})
.from(issueApprovals)
.innerJoin(issues, eq(issueApprovals.issueId, issues.id))
.where(eq(issueApprovals.approvalId, approvalId))
.orderBy(desc(issueApprovals.createdAt));
},
link: async (issueId: string, approvalId: string, actor?: LinkActor) => {
const { issue } = await assertIssueAndApprovalSameCompany(issueId, approvalId);
await db
.insert(issueApprovals)
.values({
companyId: issue.companyId,
issueId,
approvalId,
linkedByAgentId: actor?.agentId ?? null,
linkedByUserId: actor?.userId ?? null,
})
.onConflictDoNothing();
return db
.select()
.from(issueApprovals)
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)))
.then((rows) => rows[0] ?? null);
},
unlink: async (issueId: string, approvalId: string) => {
await assertIssueAndApprovalSameCompany(issueId, approvalId);
await db
.delete(issueApprovals)
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)));
},
linkManyForApproval: async (approvalId: string, issueIds: string[], actor?: LinkActor) => {
if (issueIds.length === 0) return;
const approval = await getApproval(approvalId);
if (!approval) throw notFound("Approval not found");
const uniqueIssueIds = Array.from(new Set(issueIds));
const rows = await db
.select({
id: issues.id,
companyId: issues.companyId,
})
.from(issues)
.where(inArray(issues.id, uniqueIssueIds));
if (rows.length !== uniqueIssueIds.length) {
throw notFound("One or more issues not found");
}
for (const row of rows) {
if (row.companyId !== approval.companyId) {
throw unprocessable("Issue and approval must belong to the same company");
}
}
await db
.insert(issueApprovals)
.values(
uniqueIssueIds.map((issueId) => ({
companyId: approval.companyId,
issueId,
approvalId,
linkedByAgentId: actor?.agentId ?? null,
linkedByUserId: actor?.userId ?? null,
})),
)
.onConflictDoNothing();
},
};
}

View File

@@ -3,21 +3,12 @@ import type { Db } from "@paperclip/db";
import { agents, companies, issues, issueComments } from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
const ISSUE_TRANSITIONS: Record<string, string[]> = {
backlog: ["todo", "cancelled"],
todo: ["in_progress", "blocked", "cancelled"],
in_progress: ["in_review", "blocked", "done", "cancelled"],
in_review: ["in_progress", "done", "cancelled"],
blocked: ["todo", "in_progress", "cancelled"],
done: ["todo"],
cancelled: ["todo"],
};
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
function assertTransition(from: string, to: string) {
if (from === to) return;
const allowed = ISSUE_TRANSITIONS[from] ?? [];
if (!allowed.includes(to)) {
throw conflict(`Invalid issue status transition: ${from} -> ${to}`);
if (!ALL_ISSUE_STATUSES.includes(to)) {
throw conflict(`Unknown issue status: ${to}`);
}
}
@@ -46,6 +37,29 @@ export interface IssueFilters {
}
export function issueService(db: Db) {
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
.select({
id: agents.id,
companyId: agents.companyId,
status: agents.status,
})
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
if (!assignee) throw notFound("Assignee agent not found");
if (assignee.companyId !== companyId) {
throw unprocessable("Assignee must belong to same company");
}
if (assignee.status === "pending_approval") {
throw conflict("Cannot assign work to pending approval agents");
}
if (assignee.status === "terminated") {
throw conflict("Cannot assign work to terminated agents");
}
}
return {
list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)];
@@ -77,6 +91,9 @@ export function issueService(db: Db) {
.then((rows) => rows[0] ?? null),
create: async (companyId: string, data: Omit<typeof issues.$inferInsert, "companyId">) => {
if (data.assigneeAgentId) {
await assertAssignableAgent(companyId, data.assigneeAgentId);
}
return db.transaction(async (tx) => {
const [company] = await tx
.update(companies)
@@ -123,6 +140,9 @@ export function issueService(db: Db) {
if (patch.status === "in_progress" && !patch.assigneeAgentId && !existing.assigneeAgentId) {
throw unprocessable("in_progress issues require an assignee");
}
if (data.assigneeAgentId) {
await assertAssignableAgent(existing.companyId, data.assigneeAgentId);
}
applyStatusSideEffects(data.status, patch);
if (data.status && data.status !== "done") {
@@ -148,6 +168,14 @@ export function issueService(db: Db) {
.then((rows) => rows[0] ?? null),
checkout: async (id: string, agentId: string, expectedStatuses: string[]) => {
const issueCompany = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!issueCompany) throw notFound("Issue not found");
await assertAssignableAgent(issueCompany.companyId, agentId);
const now = new Date();
const updated = await db
.update(issues)

View File

@@ -0,0 +1,29 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { approvals } from "@paperclip/db";
import type { SidebarBadges } from "@paperclip/shared";
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
export function sidebarBadgeService(db: Db) {
return {
get: async (companyId: string): Promise<SidebarBadges> => {
const actionableApprovals = await db
.select({ count: sql<number>`count(*)` })
.from(approvals)
.where(
and(
eq(approvals.companyId, companyId),
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
return {
// Inbox currently mirrors actionable approvals; expand as inbox categories grow.
inbox: actionableApprovals,
approvals: actionableApprovals,
};
},
};
}