mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
Fix imported agent approval flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c03eb18513
commit
4dd09091e3
6 files changed
+113
-15
No files matched your search
@@ -34,6 +34,7 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
activatePendingApproval: vi.fn(),
|
||||
updatePermissions: vi.fn(),
|
||||
getChainOfCommand: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
@@ -166,6 +167,7 @@ describe("agent permission routes", () => {
|
||||
mockAgentService.getChainOfCommand.mockResolvedValue([]);
|
||||
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
|
||||
mockAgentService.create.mockResolvedValue(baseAgent);
|
||||
mockAgentService.activatePendingApproval.mockResolvedValue(baseAgent);
|
||||
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
|
||||
mockAccessService.getMembership.mockResolvedValue({
|
||||
id: "membership-1",
|
||||
@@ -523,6 +525,63 @@ describe("agent permission routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows board users to directly approve pending agents", async () => {
|
||||
const pendingAgent = {
|
||||
...baseAgent,
|
||||
status: "pending_approval",
|
||||
};
|
||||
const approvedAgent = {
|
||||
...baseAgent,
|
||||
status: "idle",
|
||||
};
|
||||
mockAgentService.getById.mockResolvedValue(pendingAgent);
|
||||
mockAgentService.activatePendingApproval.mockResolvedValue(approvedAgent);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/${agentId}/approve`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith(agentId);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
action: "agent.approved",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: { source: "agent_detail" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects direct approval for agents that are not pending approval", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/${agentId}/approve`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "agent.approved",
|
||||
}));
|
||||
});
|
||||
|
||||
it("exposes explicit task assignment access on agent detail", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -1539,13 +1539,13 @@ describe("company portability", () => {
|
||||
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
projectId: "project-created",
|
||||
title: "Monday Review",
|
||||
assigneeAgentId: null,
|
||||
assigneeAgentId: "agent-created",
|
||||
priority: "high",
|
||||
status: "paused",
|
||||
concurrencyPolicy: "always_enqueue",
|
||||
catchUpPolicy: "enqueue_missed_with_cap",
|
||||
}), expect.any(Object));
|
||||
expect(result.warnings).toContain(
|
||||
expect(result.warnings).not.toContain(
|
||||
"Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.",
|
||||
);
|
||||
expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2);
|
||||
@@ -2489,7 +2489,7 @@ describe("company portability", () => {
|
||||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports new agents through approval and adapter-config normalization", async () => {
|
||||
it("imports new agents as active while preserving future hire approval settings", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
@@ -2549,7 +2549,10 @@ describe("company portability", () => {
|
||||
adapterConfig: expect.objectContaining({
|
||||
normalized: true,
|
||||
}),
|
||||
status: "pending_approval",
|
||||
status: "idle",
|
||||
}));
|
||||
expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -2098,6 +2098,37 @@ export function agentRoutes(db: Db) {
|
||||
res.json(agent);
|
||||
});
|
||||
|
||||
router.post("/agents/:id/approve", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleAgent(req, res, id);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
if (existing.status !== "pending_approval") {
|
||||
res.status(409).json({ error: "Only pending approval agents can be approved" });
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await svc.activatePendingApproval(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "agent.approved",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { source: "agent_detail" },
|
||||
});
|
||||
|
||||
res.json(agent);
|
||||
});
|
||||
|
||||
router.post("/agents/:id/terminate", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
|
||||
@@ -4209,13 +4209,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const requiresApproval =
|
||||
typeof targetCompany.requireBoardApprovalForNewAgents === "boolean"
|
||||
? targetCompany.requireBoardApprovalForNewAgents
|
||||
: include.company
|
||||
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
|
||||
: true;
|
||||
const createdStatus = requiresApproval ? "pending_approval" : "idle";
|
||||
const createdStatus = "idle";
|
||||
let created = await agents.create(targetCompany.id, {
|
||||
...patch,
|
||||
status: createdStatus,
|
||||
|
||||
@@ -146,6 +146,7 @@ export const agentsApi = {
|
||||
),
|
||||
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
|
||||
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
|
||||
approve: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/approve"), {}),
|
||||
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
||||
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
|
||||
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
|
||||
|
||||
@@ -762,12 +762,13 @@ export function AgentDetail() {
|
||||
}, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => {
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
|
||||
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
|
||||
switch (action) {
|
||||
case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "approve": return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
}
|
||||
},
|
||||
@@ -1021,9 +1022,18 @@ export function AgentDetail() {
|
||||
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
{isPendingApproval && (
|
||||
<p className="text-sm text-amber-500">
|
||||
This agent is pending board approval and cannot be invoked yet.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<span>This agent is pending board approval and cannot be invoked yet.</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => agentAction.mutate("approve")}
|
||||
disabled={agentAction.isPending}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span>Approve agent</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Save/Cancel (desktop) */}
|
||||
|
||||
Reference in new issue
Block a user