Add server routes for companies, approvals, costs, and dashboard

New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 09:07:27 -06:00
parent 8c830eae70
commit abadd469bc
29 changed files with 2151 additions and 98 deletions

View File

@@ -0,0 +1,26 @@
import type { Db } from "@paperclip/db";
import { activityLog } from "@paperclip/db";
export interface LogActivityInput {
companyId: string;
actorType: "agent" | "user" | "system";
actorId: string;
action: string;
entityType: string;
entityId: string;
agentId?: string | null;
details?: Record<string, unknown> | null;
}
export async function logActivity(db: Db, input: LogActivityInput) {
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,
actorId: input.actorId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
agentId: input.agentId ?? null,
details: input.details ?? null,
});
}

View File

@@ -1,8 +1,9 @@
import { eq, and, desc } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { activityLog } from "@paperclip/db";
export interface ActivityFilters {
companyId: string;
agentId?: string;
entityType?: string;
entityId?: string;
@@ -10,26 +11,20 @@ export interface ActivityFilters {
export function activityService(db: Db) {
return {
list: (filters?: ActivityFilters) => {
const conditions = [];
list: (filters: ActivityFilters) => {
const conditions = [eq(activityLog.companyId, filters.companyId)];
if (filters?.agentId) {
if (filters.agentId) {
conditions.push(eq(activityLog.agentId, filters.agentId));
}
if (filters?.entityType) {
if (filters.entityType) {
conditions.push(eq(activityLog.entityType, filters.entityType));
}
if (filters?.entityId) {
if (filters.entityId) {
conditions.push(eq(activityLog.entityId, filters.entityId));
}
const query = db.select().from(activityLog);
if (conditions.length > 0) {
return query.where(and(...conditions)).orderBy(desc(activityLog.createdAt));
}
return query.orderBy(desc(activityLog.createdAt));
return db.select().from(activityLog).where(and(...conditions)).orderBy(desc(activityLog.createdAt));
},
create: (data: typeof activityLog.$inferInsert) =>

View File

@@ -1,38 +1,183 @@
import { eq } from "drizzle-orm";
import { createHash, randomBytes } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents } from "@paperclip/db";
import { agents, agentApiKeys, heartbeatRuns } from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function createToken() {
return `pcp_${randomBytes(24).toString("hex")}`;
}
export function agentService(db: Db) {
async function getById(id: string) {
return db
.select()
.from(agents)
.where(eq(agents.id, id))
.then((rows) => rows[0] ?? null);
}
async function ensureManager(companyId: string, managerId: string) {
const manager = await getById(managerId);
if (!manager) throw notFound("Manager not found");
if (manager.companyId !== companyId) {
throw unprocessable("Manager must belong to same company");
}
return manager;
}
async function assertNoCycle(agentId: string, reportsTo: string | null | undefined) {
if (!reportsTo) return;
if (reportsTo === agentId) throw unprocessable("Agent cannot report to itself");
let cursor: string | null = reportsTo;
while (cursor) {
if (cursor === agentId) throw unprocessable("Reporting relationship would create cycle");
const next = await getById(cursor);
cursor = next?.reportsTo ?? null;
}
}
return {
list: () => db.select().from(agents),
list: (companyId: string) =>
db.select().from(agents).where(eq(agents.companyId, companyId)),
getById: (id: string) =>
db
.select()
.from(agents)
.where(eq(agents.id, id))
.then((rows) => rows[0] ?? null),
getById,
create: (data: typeof agents.$inferInsert) =>
db
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">) => {
if (data.reportsTo) {
await ensureManager(companyId, data.reportsTo);
}
const created = await db
.insert(agents)
.values(data)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]),
.then((rows) => rows[0]);
update: (id: string, data: Partial<typeof agents.$inferInsert>) =>
db
return 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),
.then((rows) => rows[0] ?? null);
},
remove: (id: string) =>
db
.delete(agents)
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
.update(agents)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null),
.then((rows) => rows[0] ?? null);
},
resume: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated") throw conflict("Cannot resume terminated agent");
return db
.update(agents)
.set({ status: "idle", updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
},
terminate: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
await db
.update(agents)
.set({ status: "terminated", updatedAt: new Date() })
.where(eq(agents.id, id));
await db
.update(agentApiKeys)
.set({ revokedAt: new Date() })
.where(eq(agentApiKeys.agentId, id));
return getById(id);
},
createApiKey: async (id: string, name: string) => {
const existing = await getById(id);
if (!existing) throw notFound("Agent not found");
const token = createToken();
const keyHash = hashToken(token);
const created = await db
.insert(agentApiKeys)
.values({
agentId: id,
companyId: existing.companyId,
name,
keyHash,
})
.returning()
.then((rows) => rows[0]);
return {
id: created.id,
name: created.name,
token,
createdAt: created.createdAt,
};
},
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 key = row.reportsTo ?? null;
const group = byManager.get(key) ?? [];
group.push(row);
byManager.set(key, group);
}
const build = (managerId: string | null): Array<Record<string, unknown>> => {
const members = byManager.get(managerId) ?? [];
return members.map((member) => ({
...member,
reports: build(member.id),
}));
};
return build(null);
},
runningForAgent: (agentId: string) =>
db
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))),
};
}

View File

@@ -0,0 +1,113 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { approvals } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
import { agentService } from "./agents.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
return {
list: (companyId: string, status?: string) => {
const conditions = [eq(approvals.companyId, companyId)];
if (status) conditions.push(eq(approvals.status, status));
return db.select().from(approvals).where(and(...conditions));
},
getById: (id: string) =>
db
.select()
.from(approvals)
.where(eq(approvals.id, id))
.then((rows) => rows[0] ?? null),
create: (companyId: string, data: Omit<typeof approvals.$inferInsert, "companyId">) =>
db
.insert(approvals)
.values({ ...data, companyId })
.returning()
.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 now = new Date();
const updated = await db
.update(approvals)
.set({
status: "approved",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
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>)
: {},
contextMode: String(payload.contextMode ?? "thin"),
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,
});
}
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 now = new Date();
return db
.update(approvals)
.set({
status: "rejected",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
},
};
}

View File

@@ -0,0 +1,39 @@
import { eq } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { companies } from "@paperclip/db";
export function companyService(db: Db) {
return {
list: () => db.select().from(companies),
getById: (id: string) =>
db
.select()
.from(companies)
.where(eq(companies.id, id))
.then((rows) => rows[0] ?? null),
create: (data: typeof companies.$inferInsert) =>
db
.insert(companies)
.values(data)
.returning()
.then((rows) => rows[0]),
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
db
.update(companies)
.set({ ...data, updatedAt: new Date() })
.where(eq(companies.id, id))
.returning()
.then((rows) => rows[0] ?? null),
archive: (id: string) =>
db
.update(companies)
.set({ status: "archived", updatedAt: new Date() })
.where(eq(companies.id, id))
.returning()
.then((rows) => rows[0] ?? null),
};
}

View File

@@ -0,0 +1,112 @@
import { and, desc, eq, isNotNull, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, companies, costEvents } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
export function costService(db: Db) {
return {
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
const agent = await db
.select()
.from(agents)
.where(eq(agents.id, data.agentId))
.then((rows) => rows[0] ?? null);
if (!agent) throw notFound("Agent not found");
if (agent.companyId !== companyId) {
throw unprocessable("Agent does not belong to company");
}
const event = await db
.insert(costEvents)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]);
await db
.update(agents)
.set({
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${event.costCents}`,
updatedAt: new Date(),
})
.where(eq(agents.id, event.agentId));
await db
.update(companies)
.set({
spentMonthlyCents: sql`${companies.spentMonthlyCents} + ${event.costCents}`,
updatedAt: new Date(),
})
.where(eq(companies.id, companyId));
const updatedAgent = await db
.select()
.from(agents)
.where(eq(agents.id, event.agentId))
.then((rows) => rows[0] ?? null);
if (
updatedAgent &&
updatedAgent.budgetMonthlyCents > 0 &&
updatedAgent.spentMonthlyCents >= updatedAgent.budgetMonthlyCents &&
updatedAgent.status !== "paused" &&
updatedAgent.status !== "terminated"
) {
await db
.update(agents)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(agents.id, updatedAgent.id));
}
return event;
},
summary: async (companyId: string) => {
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
const utilization =
company.budgetMonthlyCents > 0
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
: 0;
return {
companyId,
monthSpendCents: company.spentMonthlyCents,
monthBudgetCents: company.budgetMonthlyCents,
monthUtilizationPercent: Number(utilization.toFixed(2)),
};
},
byAgent: async (companyId: string) =>
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)`,
})
.from(costEvents)
.where(eq(costEvents.companyId, companyId))
.groupBy(costEvents.agentId)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
byProject: async (companyId: string) =>
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)`,
})
.from(costEvents)
.where(and(eq(costEvents.companyId, companyId), isNotNull(costEvents.projectId)))
.groupBy(costEvents.projectId)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
};
}

View File

@@ -0,0 +1,96 @@
import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, approvals, companies, issues } from "@paperclip/db";
import { notFound } from "../errors.js";
export function dashboardService(db: Db) {
return {
summary: async (companyId: string) => {
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
const agentRows = await db
.select({ status: agents.status, count: sql<number>`count(*)` })
.from(agents)
.where(eq(agents.companyId, companyId))
.groupBy(agents.status);
const taskRows = await db
.select({ status: issues.status, count: sql<number>`count(*)` })
.from(issues)
.where(eq(issues.companyId, companyId))
.groupBy(issues.status);
const pendingApprovals = await db
.select({ count: sql<number>`count(*)` })
.from(approvals)
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
.then((rows) => Number(rows[0]?.count ?? 0));
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
const staleTasks = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
const agentCounts: Record<string, number> = {
active: 0,
running: 0,
paused: 0,
error: 0,
};
for (const row of agentRows) {
agentCounts[row.status] = Number(row.count);
}
const taskCounts: Record<string, number> = {
open: 0,
inProgress: 0,
blocked: 0,
done: 0,
};
for (const row of taskRows) {
const count = Number(row.count);
if (row.status === "in_progress") taskCounts.inProgress += count;
if (row.status === "blocked") taskCounts.blocked += count;
if (row.status === "done") taskCounts.done += count;
if (row.status !== "done" && row.status !== "cancelled") taskCounts.open += count;
}
const utilization =
company.budgetMonthlyCents > 0
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
: 0;
return {
companyId,
agents: {
active: agentCounts.active,
running: agentCounts.running,
paused: agentCounts.paused,
error: agentCounts.error,
},
tasks: taskCounts,
costs: {
monthSpendCents: company.spentMonthlyCents,
monthBudgetCents: company.budgetMonthlyCents,
monthUtilizationPercent: Number(utilization.toFixed(2)),
},
pendingApprovals,
staleTasks,
};
},
};
}

View File

@@ -4,7 +4,7 @@ import { goals } from "@paperclip/db";
export function goalService(db: Db) {
return {
list: () => db.select().from(goals),
list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
getById: (id: string) =>
db
@@ -13,10 +13,10 @@ export function goalService(db: Db) {
.where(eq(goals.id, id))
.then((rows) => rows[0] ?? null),
create: (data: typeof goals.$inferInsert) =>
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
db
.insert(goals)
.values(data)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]),

View File

@@ -0,0 +1,354 @@
import { spawn, type ChildProcess } from "node:child_process";
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, heartbeatRuns } from "@paperclip/db";
import { conflict, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
interface RunningProcess {
child: ChildProcess;
graceSec: number;
}
const runningProcesses = new Map<string, RunningProcess>();
function parseObject(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function asNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
export function heartbeatService(db: Db) {
async function getAgent(agentId: string) {
return db
.select()
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
}
async function setRunStatus(
runId: string,
status: string,
patch?: Partial<typeof heartbeatRuns.$inferInsert>,
) {
return db
.update(heartbeatRuns)
.set({ status, ...patch, updatedAt: new Date() })
.where(eq(heartbeatRuns.id, runId))
.returning()
.then((rows) => rows[0] ?? null);
}
async function finalizeAgentStatus(agentId: string, ok: boolean) {
const existing = await getAgent(agentId);
if (!existing) return;
if (existing.status === "paused" || existing.status === "terminated") {
return;
}
await db
.update(agents)
.set({
status: ok ? "idle" : "error",
lastHeartbeatAt: new Date(),
updatedAt: new Date(),
})
.where(eq(agents.id, agentId));
}
async function executeHttpRun(runId: string, agentId: string, config: Record<string, unknown>, context: Record<string, unknown>) {
const url = asString(config.url, "");
if (!url) throw new Error("HTTP adapter missing url");
const method = asString(config.method, "POST");
const timeoutMs = asNumber(config.timeoutMs, 15000);
const headers = parseObject(config.headers) as Record<string, string>;
const payloadTemplate = parseObject(config.payloadTemplate);
const body = { ...payloadTemplate, agentId, runId, context };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method,
headers: {
"content-type": "application/json",
...headers,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`HTTP invoke failed with status ${res.status}`);
}
} finally {
clearTimeout(timer);
}
}
async function executeProcessRun(
runId: string,
_agentId: string,
config: Record<string, unknown>,
) {
const command = asString(config.command, "");
if (!command) throw new Error("Process adapter missing command");
const args = asStringArray(config.args);
const cwd = typeof config.cwd === "string" ? config.cwd : process.cwd();
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
const timeoutSec = asNumber(config.timeoutSec, 900);
const graceSec = asNumber(config.graceSec, 15);
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: { ...process.env, ...env },
});
runningProcesses.set(runId, { child, graceSec });
const timeout = setTimeout(async () => {
child.kill("SIGTERM");
await setRunStatus(runId, "timed_out", {
error: `Timed out after ${timeoutSec}s`,
finishedAt: new Date(),
});
runningProcesses.delete(runId);
resolve();
}, timeoutSec * 1000);
child.stdout?.on("data", (chunk) => {
logger.info({ runId, output: String(chunk) }, "agent process stdout");
});
child.stderr?.on("data", (chunk) => {
logger.warn({ runId, output: String(chunk) }, "agent process stderr");
});
child.on("error", (err) => {
clearTimeout(timeout);
runningProcesses.delete(runId);
reject(err);
});
child.on("exit", (code, signal) => {
clearTimeout(timeout);
runningProcesses.delete(runId);
if (signal) {
resolve();
return;
}
if (code === 0) {
resolve();
return;
}
reject(new Error(`Process exited with code ${code ?? -1}`));
});
});
}
async function executeRun(runId: string) {
const run = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!run) {
return;
}
const agent = await getAgent(run.agentId);
if (!agent) {
await setRunStatus(runId, "failed", {
error: "Agent not found",
finishedAt: new Date(),
});
return;
}
await setRunStatus(run.id, "running", { startedAt: new Date() });
await db
.update(agents)
.set({ status: "running", updatedAt: new Date() })
.where(eq(agents.id, agent.id));
try {
const config = parseObject(agent.adapterConfig);
const context = (run.contextSnapshot ?? {}) as Record<string, unknown>;
if (agent.adapterType === "http") {
await executeHttpRun(run.id, agent.id, config, context);
} else {
await executeProcessRun(run.id, agent.id, config);
}
const latestRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, run.id))
.then((rows) => rows[0] ?? null);
if (latestRun?.status === "timed_out" || latestRun?.status === "cancelled") {
await finalizeAgentStatus(agent.id, false);
return;
}
await setRunStatus(run.id, "succeeded", { finishedAt: new Date(), error: null });
await finalizeAgentStatus(agent.id, true);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown adapter failure";
await setRunStatus(run.id, "failed", {
error: message,
finishedAt: new Date(),
});
await finalizeAgentStatus(agent.id, false);
}
}
return {
list: (companyId: string, agentId?: string) => {
if (!agentId) {
return db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
}
return db
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)));
},
invoke: async (
agentId: string,
invocationSource: "scheduler" | "manual" | "callback" = "manual",
contextSnapshot: Record<string, unknown> = {},
) => {
const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
if (agent.status === "paused" || agent.status === "terminated") {
throw conflict("Agent is not invokable in its current state", { status: agent.status });
}
const activeRun = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.agentId, agentId),
inArray(heartbeatRuns.status, ["queued", "running"]),
),
)
.then((rows) => rows[0] ?? null);
if (activeRun) {
throw conflict("Agent already has an active heartbeat run", { runId: activeRun.id });
}
const run = await db
.insert(heartbeatRuns)
.values({
companyId: agent.companyId,
agentId,
invocationSource,
status: "queued",
contextSnapshot,
})
.returning()
.then((rows) => rows[0]);
void executeRun(run.id).catch((err) => {
logger.error({ err, runId: run.id }, "heartbeat execution failed");
});
return run;
},
cancelRun: async (runId: string) => {
const run = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!run) throw notFound("Heartbeat run not found");
if (run.status !== "running" && run.status !== "queued") return run;
const running = runningProcesses.get(run.id);
if (running) {
running.child.kill("SIGTERM");
const graceMs = Math.max(1, running.graceSec) * 1000;
setTimeout(() => {
if (!running.child.killed) {
running.child.kill("SIGKILL");
}
}, graceMs);
}
const cancelled = await setRunStatus(run.id, "cancelled", {
finishedAt: new Date(),
error: "Cancelled by control plane",
});
runningProcesses.delete(run.id);
return cancelled;
},
cancelActiveForAgent: async (agentId: string) => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.agentId, agentId),
inArray(heartbeatRuns.status, ["queued", "running"]),
),
);
for (const run of runs) {
await db
.update(heartbeatRuns)
.set({
status: "cancelled",
finishedAt: new Date(),
error: "Cancelled due to agent pause",
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
const running = runningProcesses.get(run.id);
if (running) {
running.child.kill("SIGTERM");
runningProcesses.delete(run.id);
}
}
return runs.length;
},
};
}

View File

@@ -1,5 +1,11 @@
export { companyService } from "./companies.js";
export { agentService } from "./agents.js";
export { projectService } from "./projects.js";
export { issueService } from "./issues.js";
export { issueService, type IssueFilters } from "./issues.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 { logActivity, type LogActivityInput } from "./activity-log.js";

View File

@@ -1,10 +1,62 @@
import { eq } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { issues } from "@paperclip/db";
import { 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: [],
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}`);
}
}
function applyStatusSideEffects(
status: string | undefined,
patch: Partial<typeof issues.$inferInsert>,
): Partial<typeof issues.$inferInsert> {
if (!status) return patch;
if (status === "in_progress" && !patch.startedAt) {
patch.startedAt = new Date();
}
if (status === "done") {
patch.completedAt = new Date();
}
if (status === "cancelled") {
patch.cancelledAt = new Date();
}
return patch;
}
export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
projectId?: string;
}
export function issueService(db: Db) {
return {
list: () => db.select().from(issues),
list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)];
if (filters?.status) conditions.push(eq(issues.status, filters.status));
if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
return db.select().from(issues).where(and(...conditions)).orderBy(desc(issues.updatedAt));
},
getById: (id: string) =>
db
@@ -13,20 +65,55 @@ export function issueService(db: Db) {
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null),
create: (data: typeof issues.$inferInsert) =>
db
.insert(issues)
.values(data)
.returning()
.then((rows) => rows[0]),
create: (companyId: string, data: Omit<typeof issues.$inferInsert, "companyId">) => {
const values = { ...data, companyId } as typeof issues.$inferInsert;
if (values.status === "in_progress" && !values.startedAt) {
values.startedAt = new Date();
}
if (values.status === "done") {
values.completedAt = new Date();
}
if (values.status === "cancelled") {
values.cancelledAt = new Date();
}
update: (id: string, data: Partial<typeof issues.$inferInsert>) =>
db
return db
.insert(issues)
.values(values)
.returning()
.then((rows) => rows[0]);
},
update: async (id: string, data: Partial<typeof issues.$inferInsert>) => {
const existing = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (data.status) {
assertTransition(existing.status, data.status);
}
const patch: Partial<typeof issues.$inferInsert> = {
...data,
updatedAt: new Date(),
};
if (patch.status === "in_progress" && !patch.assigneeAgentId && !existing.assigneeAgentId) {
throw unprocessable("in_progress issues require an assignee");
}
applyStatusSideEffects(data.status, patch);
return db
.update(issues)
.set({ ...data, updatedAt: new Date() })
.set(patch)
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null),
.then((rows) => rows[0] ?? null);
},
remove: (id: string) =>
db
@@ -34,5 +121,116 @@ export function issueService(db: Db) {
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null),
checkout: async (id: string, agentId: string, expectedStatuses: string[]) => {
const now = new Date();
const updated = await db
.update(issues)
.set({
assigneeAgentId: agentId,
status: "in_progress",
startedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, id),
inArray(issues.status, expectedStatuses),
or(isNull(issues.assigneeAgentId), eq(issues.assigneeAgentId, agentId)),
),
)
.returning()
.then((rows) => rows[0] ?? null);
if (updated) return updated;
const current = await db
.select({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
})
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Issue not found");
throw conflict("Issue checkout conflict", {
issueId: current.id,
status: current.status,
assigneeAgentId: current.assigneeAgentId,
});
},
release: async (id: string, actorAgentId?: string) => {
const existing = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (actorAgentId && existing.assigneeAgentId && existing.assigneeAgentId !== actorAgentId) {
throw conflict("Only assignee can release issue");
}
return db
.update(issues)
.set({
status: "todo",
assigneeAgentId: null,
updatedAt: new Date(),
})
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
},
listComments: (issueId: string) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt)),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
return db
.insert(issueComments)
.values({
companyId: issue.companyId,
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
body,
})
.returning()
.then((rows) => rows[0]);
},
staleCount: async (companyId: string, minutes = 60) => {
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
const result = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
),
)
.then((rows) => rows[0]);
return Number(result?.count ?? 0);
},
};
}

View File

@@ -4,7 +4,7 @@ import { projects } from "@paperclip/db";
export function projectService(db: Db) {
return {
list: () => db.select().from(projects),
list: (companyId: string) => db.select().from(projects).where(eq(projects.companyId, companyId)),
getById: (id: string) =>
db
@@ -13,10 +13,10 @@ export function projectService(db: Db) {
.where(eq(projects.id, id))
.then((rows) => rows[0] ?? null),
create: (data: typeof projects.$inferInsert) =>
create: (companyId: string, data: Omit<typeof projects.$inferInsert, "companyId">) =>
db
.insert(projects)
.values(data)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]),