From 365b6d9bd85e493f72f079b20f3dc3347a6b229f Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 10:36:31 -0500 Subject: [PATCH] Add generic issue-linked board approvals Co-Authored-By: Paperclip --- cli/src/__tests__/company.test.ts | 2 + packages/shared/src/constants.ts | 7 +- .../approval-routes-idempotency.test.ts | 70 ++++++++++++++ skills/paperclip/SKILL.md | 32 +++++++ ui/src/components/ApprovalCard.tsx | 39 ++++---- ui/src/components/ApprovalPayload.tsx | 2 + ui/src/components/CommentThread.test.tsx | 92 ++++++++++++++++++- ui/src/components/CommentThread.tsx | 60 +++++++++++- ui/src/pages/Approvals.tsx | 3 + ui/src/pages/IssueDetail.tsx | 77 +++++++++++++--- 10 files changed, 345 insertions(+), 39 deletions(-) diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index 268d1266..2d47d8b5 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -220,6 +220,7 @@ describe("renderCompanyImportPreview", () => { status: null, executionWorkspacePolicy: null, workspaces: [], + env: null, metadata: null, }, ], @@ -432,6 +433,7 @@ describe("import selection catalog", () => { status: null, executionWorkspacePolicy: null, workspaces: [], + env: null, metadata: null, }, ], diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 61023830..fd80f43b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -200,7 +200,12 @@ export const PROJECT_COLORS = [ "#3b82f6", // blue ] as const; -export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy", "budget_override_required"] as const; +export const APPROVAL_TYPES = [ + "hire_agent", + "approve_ceo_strategy", + "budget_override_required", + "request_board_approval", +] as const; export type ApprovalType = (typeof APPROVAL_TYPES)[number]; export const APPROVAL_STATUSES = [ diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 51181ff5..83d34cf1 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -57,6 +57,24 @@ function createApp() { return app; } +function createAgentApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "api_key", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", approvalRoutes({} as any)); + app.use(errorHandler); + return app; +} + describe("approval routes idempotent retries", () => { beforeEach(() => { vi.clearAllMocks(); @@ -107,4 +125,56 @@ describe("approval routes idempotent retries", () => { expect(res.status).toBe(200); expect(mockLogActivity).not.toHaveBeenCalled(); }); + + it("lets agents create generic issue-linked board approval requests", async () => { + mockApprovalService.create.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "request_board_approval", + requestedByAgentId: "agent-1", + requestedByUserId: null, + status: "pending", + payload: { title: "Approve hosting spend" }, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + createdAt: new Date("2026-04-06T00:00:00.000Z"), + updatedAt: new Date("2026-04-06T00:00:00.000Z"), + }); + + const res = await request(createAgentApp()) + .post("/api/companies/company-1/approvals") + .send({ + type: "request_board_approval", + issueIds: ["00000000-0000-0000-0000-000000000001"], + payload: { title: "Approve hosting spend" }, + }); + + expect(res.status).toBe(201); + expect(mockApprovalService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + type: "request_board_approval", + requestedByAgentId: "agent-1", + requestedByUserId: null, + status: "pending", + decisionNote: null, + }), + ); + expect(mockSecretService.normalizeHireApprovalPayloadForPersistence).not.toHaveBeenCalled(); + expect(mockIssueApprovalService.linkManyForApproval).toHaveBeenCalledWith( + "approval-1", + ["00000000-0000-0000-0000-000000000001"], + { agentId: "agent-1", userId: null }, + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + actorType: "agent", + actorId: "agent-1", + action: "approval.created", + }), + ); + }); }); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 3cd9eb06..5ef76086 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -133,6 +133,37 @@ If a blocker is moved to `cancelled`, it does **not** count as resolved for bloc When you receive one of these wake reasons, check the issue state and continue the work or mark it done. +## Requesting Board Approval + +Agents can create approval requests for arbitrary issue-linked work. Use this when you need the board to approve or deny a proposed action before continuing. + +Recommended generic type: + +- `request_board_approval` for open-ended approval requests like spend approval, vendor approval, launch approval, or other board decisions + +Create the approval and link it to the relevant issue in one call: + +```json +POST /api/companies/{companyId}/approvals +{ + "type": "request_board_approval", + "requestedByAgentId": "{your-agent-id}", + "issueIds": ["{issue-id}"], + "payload": { + "title": "Approve monthly hosting spend", + "summary": "Estimated cost is $42/month for provider X.", + "recommendedAction": "Approve provider X and continue setup.", + "risks": ["Costs may increase with usage."] + } +} +``` + +Notes: + +- `issueIds` links the approval into the issue thread/UI. +- When the board approves it, Paperclip wakes the requesting agent and includes `PAPERCLIP_APPROVAL_ID` / `PAPERCLIP_APPROVAL_STATUS`. +- Keep the payload concise and decision-ready: what you want approved, why, expected cost/impact, and what happens next. + ## Project Setup Workflow (CEO/Manager Common Path) When asked to set up a new project with workspace config (local folder and/or GitHub repo), use: @@ -335,6 +366,7 @@ PATCH /api/agents/{agentId}/instructions-path | Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | | Release task | `POST /api/issues/:issueId/release` | | List agents | `GET /api/companies/:companyId/agents` | +| Create approval | `POST /api/companies/:companyId/approvals` | | List company skills | `GET /api/companies/:companyId/skills` | | Import company skills | `POST /api/companies/:companyId/skills/import` | | Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` | diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx index 2123fc57..2ad0ff9a 100644 --- a/ui/src/components/ApprovalCard.tsx +++ b/ui/src/components/ApprovalCard.tsx @@ -21,19 +21,22 @@ export function ApprovalCard({ onReject, onOpen, detailLink, - isPending, + isPending = false, + pendingAction = null, }: { approval: Approval; requesterAgent: Agent | null; - onApprove: () => void; - onReject: () => void; + onApprove?: () => void; + onReject?: () => void; onOpen?: () => void; detailLink?: string; - isPending: boolean; + isPending?: boolean; + pendingAction?: "approve" | "reject" | null; }) { const Icon = typeIcon[approval.type] ?? defaultTypeIcon; const label = approvalLabel(approval.type, approval.payload as Record | null); const showResolutionButtons = + Boolean(onApprove && onReject) && approval.type !== "budget_override_required" && (approval.status === "pending" || approval.status === "revision_requested"); @@ -78,7 +81,7 @@ export function ApprovalCard({ onClick={onApprove} disabled={isPending} > - Approve + {pendingAction === "approve" ? "Approving..." : "Approve"} )} -
- {detailLink ? ( - - ) : ( - - )} -
+ {(detailLink || onOpen) ? ( +
+ {detailLink ? ( + + ) : ( + + )} +
+ ) : null} ); } diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx index 83b55c73..f3258cca 100644 --- a/ui/src/components/ApprovalPayload.tsx +++ b/ui/src/components/ApprovalPayload.tsx @@ -5,6 +5,7 @@ export const typeLabel: Record = { hire_agent: "Hire Agent", approve_ceo_strategy: "CEO Strategy", budget_override_required: "Budget Override", + request_board_approval: "Board Approval", }; /** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */ @@ -20,6 +21,7 @@ export const typeIcon: Record = { hire_agent: UserPlus, approve_ceo_strategy: Lightbulb, budget_override_required: ShieldAlert, + request_board_approval: ShieldCheck, }; export const defaultTypeIcon = ShieldCheck; diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index aa972337..41c23bed 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -4,7 +4,7 @@ import { act } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; -import type { Agent } from "@paperclipai/shared"; +import type { Agent, Approval } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CommentThread } from "./CommentThread"; @@ -33,6 +33,25 @@ vi.mock("./InlineEntitySelector", () => ({ InlineEntitySelector: () => null, })); +vi.mock("./ApprovalCard", () => ({ + ApprovalCard: ({ + approval, + onApprove, + onReject, + }: { + approval: Approval; + onApprove?: () => void; + onReject?: () => void; + }) => ( +
+
{approval.type}
+
{String(approval.payload.title ?? "")}
+ {onApprove ? : null} + {onReject ? : null} +
+ ), +})); + vi.mock("@/plugins/slots", () => ({ PluginSlotOutlet: () => null, })); @@ -144,4 +163,75 @@ describe("CommentThread", () => { root.unmount(); }); }); + + it("renders linked approvals inline in the timeline", () => { + const root = createRoot(container); + const agent: Agent = { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + urlKey: "codexcoder", + role: "engineer", + title: null, + icon: "code", + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + }; + const approval: Approval = { + id: "approval-1", + companyId: "company-1", + type: "request_board_approval", + requestedByAgentId: "agent-1", + requestedByUserId: null, + status: "pending", + payload: { + title: "Approve hosting spend", + text: "Estimated monthly cost is $42.", + }, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + createdAt: new Date("2026-03-11T09:00:00.000Z"), + updatedAt: new Date("2026-03-11T09:00:00.000Z"), + }; + + act(() => { + root.render( + + {}} + onApproveApproval={async () => {}} + onRejectApproval={async () => {}} + /> + , + ); + }); + + const approvalRow = container.querySelector("#approval-approval-1") as HTMLDivElement | null; + expect(approvalRow).not.toBeNull(); + expect(container.textContent).toContain("request_board_approval"); + expect(container.textContent).toContain("Approve hosting spend"); + expect(container.textContent).toContain("Approve"); + expect(container.textContent).toContain("Reject"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 79f58702..6d9eba44 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re import { Link, useLocation } from "react-router-dom"; import type { Agent, + Approval, FeedbackDataSharingPreference, FeedbackVote, FeedbackVoteValue, @@ -15,7 +16,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; -import { StatusBadge } from "./StatusBadge"; +import { ApprovalCard } from "./ApprovalCard"; import { AgentIcon } from "./AgentIconPicker"; import { formatAssigneeUserLabel } from "../lib/assignees"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; @@ -50,6 +51,7 @@ interface CommentReassignment { interface CommentThreadProps { comments: CommentWithRunMeta[]; queuedComments?: CommentWithRunMeta[]; + linkedApprovals?: Approval[]; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; @@ -57,6 +59,12 @@ interface CommentThreadProps { timelineEvents?: IssueTimelineEvent[]; companyId?: string | null; projectId?: string | null; + onApproveApproval?: (approvalId: string) => Promise; + onRejectApproval?: (approvalId: string) => Promise; + pendingApprovalAction?: { + approvalId: string; + action: "approve" | "reject"; + } | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, @@ -375,6 +383,7 @@ function CommentCard({ type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } + | { kind: "approval"; id: string; createdAtMs: number; approval: Approval } | { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; @@ -447,6 +456,9 @@ const TimelineList = memo(function TimelineList({ currentUserId, companyId, projectId, + onApproveApproval, + onRejectApproval, + pendingApprovalAction, feedbackVoteByTargetId, feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, @@ -459,6 +471,12 @@ const TimelineList = memo(function TimelineList({ currentUserId?: string | null; companyId?: string | null; projectId?: string | null; + onApproveApproval?: (approvalId: string) => Promise; + onRejectApproval?: (approvalId: string) => Promise; + pendingApprovalAction?: { + approvalId: string; + action: "approve" | "reject"; + } | null; feedbackVoteByTargetId?: Map; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; @@ -488,6 +506,24 @@ const TimelineList = memo(function TimelineList({ ); } + if (item.kind === "approval") { + const approval = item.approval; + const isPending = pendingApprovalAction?.approvalId === approval.id; + return ( +
+ void onApproveApproval(approval.id) : undefined} + onReject={onRejectApproval ? () => void onRejectApproval(approval.id) : undefined} + detailLink={`/approvals/${approval.id}`} + isPending={isPending} + pendingAction={isPending ? pendingApprovalAction?.action ?? null : null} + /> +
+ ); + } + if (item.kind === "run") { const run = item.run; const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); @@ -548,6 +584,7 @@ const TimelineList = memo(function TimelineList({ export function CommentThread({ comments, queuedComments = [], + linkedApprovals = [], feedbackVotes = [], feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, @@ -555,6 +592,9 @@ export function CommentThread({ timelineEvents = [], companyId, projectId, + onApproveApproval, + onRejectApproval, + pendingApprovalAction = null, onVote, onAdd, agentMap, @@ -593,6 +633,12 @@ export function CommentThread({ createdAtMs: new Date(comment.createdAt).getTime(), comment, })); + const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({ + kind: "approval", + id: approval.id, + createdAtMs: new Date(approval.createdAt).getTime(), + approval, + })); const eventItems: TimelineItem[] = timelineEvents.map((event) => ({ kind: "event", id: event.id, @@ -605,17 +651,18 @@ export function CommentThread({ createdAtMs: new Date(runTimestamp(run)).getTime(), run, })); - return [...commentItems, ...eventItems, ...runItems].sort((a, b) => { + return [...commentItems, ...approvalItems, ...eventItems, ...runItems].sort((a, b) => { if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs; if (a.kind === b.kind) return a.id.localeCompare(b.id); const kindOrder = { event: 0, - comment: 1, - run: 2, + approval: 1, + comment: 2, + run: 3, } as const; return kindOrder[a.kind] - kindOrder[b.kind]; }); - }, [comments, timelineEvents, linkedRuns]); + }, [comments, linkedApprovals, timelineEvents, linkedRuns]); const feedbackVoteByTargetId = useMemo(() => { const map = new Map(); @@ -754,6 +801,9 @@ export function CommentThread({ currentUserId={currentUserId} companyId={companyId} projectId={projectId} + onApproveApproval={onApproveApproval} + onRejectApproval={onRejectApproval} + pendingApprovalAction={pendingApprovalAction} feedbackVoteByTargetId={feedbackVoteByTargetId} feedbackDataSharingPreference={feedbackDataSharingPreference} onVote={onVote ? handleFeedbackVote : undefined} diff --git a/ui/src/pages/Approvals.tsx b/ui/src/pages/Approvals.tsx index 24c8da0a..a8fae370 100644 --- a/ui/src/pages/Approvals.tsx +++ b/ui/src/pages/Approvals.tsx @@ -123,6 +123,9 @@ export function Approvals() { onReject={() => rejectMutation.mutate(approval.id)} detailLink={`/approvals/${approval.id}`} isPending={approveMutation.isPending || rejectMutation.isPending} + pendingAction={ + approveMutation.isPending ? "approve" : rejectMutation.isPending ? "reject" : null + } /> ))} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 84aadbbb..c1602d34 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3,6 +3,7 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; +import { approvalsApi } from "../api/approvals"; import { activityApi } from "../api/activity"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -37,6 +38,7 @@ import { import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; +import { ApprovalCard } from "../components/ApprovalCard"; import { CommentThread } from "../components/CommentThread"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueProperties } from "../components/IssueProperties"; @@ -47,7 +49,6 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal"; import { ScrollToBottom } from "../components/ScrollToBottom"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; -import { StatusBadge } from "../components/StatusBadge"; import { Identity } from "../components/Identity"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; import { PluginLauncherOutlet } from "@/plugins/launchers"; @@ -303,6 +304,10 @@ export function IssueDetail() { const [secondaryOpen, setSecondaryOpen] = useState({ approvals: false, }); + const [pendingApprovalAction, setPendingApprovalAction] = useState<{ + approvalId: string; + action: "approve" | "reject"; + } | null>(null); const [attachmentError, setAttachmentError] = useState(null); const [attachmentDragActive, setAttachmentDragActive] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false); @@ -659,6 +664,39 @@ export function IssueDetail() { }, }); + const approvalDecision = useMutation({ + mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => { + if (action === "approve") { + return approvalsApi.approve(approvalId); + } + return approvalsApi.reject(approvalId); + }, + onMutate: ({ approvalId, action }) => { + setPendingApprovalAction({ approvalId, action }); + }, + onSuccess: (_approval, variables) => { + invalidateIssue(); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) }); + if (resolvedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) }); + } + pushToast({ + title: variables.action === "approve" ? "Approval approved" : "Approval rejected", + tone: "success", + }); + }, + onError: (err, variables) => { + pushToast({ + title: variables.action === "approve" ? "Approval failed" : "Rejection failed", + body: err instanceof Error ? err.message : "Unable to update approval", + tone: "error", + }); + }, + onSettled: () => { + setPendingApprovalAction(null); + }, + }); + const addComment = useMutation({ mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) => issuesApi.addComment(issueId!, body, reopen, interrupt), @@ -1543,6 +1581,7 @@ export function IssueDetail() { { + await approvalDecision.mutateAsync({ approvalId, action: "approve" }); + }} + onRejectApproval={async (approvalId) => { + await approvalDecision.mutateAsync({ approvalId, action: "reject" }); + }} + pendingApprovalAction={pendingApprovalAction} issueStatus={issue.status} agentMap={agentMap} currentUserId={currentUserId} @@ -1703,20 +1749,21 @@ export function IssueDetail() {
{linkedApprovals.map((approval) => ( - -
- - - {approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} - - {approval.id.slice(0, 8)} -
- {relativeTime(approval.createdAt)} - +
+ approvalDecision.mutate({ approvalId: approval.id, action: "approve" })} + onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })} + detailLink={`/approvals/${approval.id}`} + isPending={pendingApprovalAction?.approvalId === approval.id} + pendingAction={ + pendingApprovalAction?.approvalId === approval.id + ? pendingApprovalAction.action + : null + } + /> +
))}