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

Agent management: hire endpoint with permission gates and pending_approval status,
config revision tracking with rollback, agent duplicate route, permission CRUD.
Block pending_approval agents from auth, heartbeat, and assignments.

Approvals: revision request/resubmit flow, approval comments CRUD, issue-approval
linking, auto-wake agents on approval decisions with context snapshot.

Costs: per-agent breakdown, period filtering (month/week/day/all), cost by agent
list endpoint.

Adapters: agentConfigurationDoc on all adapters, /llms/agent-configuration.txt
reflection routes. Inject PAPERCLIP_APPROVAL_ID, PAPERCLIP_APPROVAL_STATUS,
PAPERCLIP_LINKED_ISSUE_IDS into adapter environments.

Sidebar badges endpoint for pending approval/inbox counts. Dashboard and company
settings extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 13:02:41 -06:00
parent db0b19bf9d
commit c09037ffad
28 changed files with 2393 additions and 148 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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