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

@@ -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)