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:
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "PAPERCLIP_UI_DEV_MIDDLEWARE=true pnpm --filter @paperclip/server dev",
|
||||
"dev:watch": "PAPERCLIP_UI_DEV_MIDDLEWARE=true PAPERCLIP_MIGRATION_PROMPT=never pnpm --filter @paperclip/server dev:watch",
|
||||
"dev:server": "pnpm --filter @paperclip/server dev",
|
||||
"dev:ui": "pnpm --filter @paperclip/ui dev",
|
||||
"build": "pnpm -r build",
|
||||
|
||||
718
pnpm-lock.yaml
generated
718
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { getServerAdapter, listAdapterModels } from "./registry.js";
|
||||
export { getServerAdapter, listAdapterModels, listServerAdapters } from "./registry.js";
|
||||
export type {
|
||||
ServerAdapterModule,
|
||||
AdapterExecutionContext,
|
||||
|
||||
@@ -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
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface NormalizedAgentPermissions {
|
||||
export type NormalizedAgentPermissions = Record<string, unknown> & {
|
||||
canCreateAgents: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions {
|
||||
return {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
169
server/src/services/issue-approvals.ts
Normal file
169
server/src/services/issue-approvals.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
29
server/src/services/sidebar-badges.ts
Normal file
29
server/src/services/sidebar-badges.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user