fix(server): authorize agent resume through direct grants (#12047)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip keeps agent lifecycle changes behind control-plane authorization > - Plugins can create agents in a paused state until an operator activates them > - An agent with a direct configuration grant could not resume these agents > - A paused plugin-managed agent also had no stable provenance in its pause reason > - This pull request adds one protected resume path and preserves every other lifecycle gate > - The benefit is safe recovery from plugin provisioning without a broad permission change ## Linked Issues or Issue Description Refs #8168. That pull request uses a role capability and also opens clear-error. This change uses the current grant system and keeps clear-error closed. **What happened?** A plugin can create a paused managed agent. An agent actor cannot resume that agent, even when the actor has a direct `agents:configure` grant. The paused agent can also have a null pause reason. **Expected behavior** An agent with a direct `agents:configure` grant can resume an accessible paused agent. An agent without that grant cannot resume it. Plugin-managed paused agents show stable plugin provenance. A completed resume stays in effect after reconcile. **Steps to reproduce** 1. Install a plugin that declares a managed agent with `status: paused`. 2. Give a same-company agent a direct `agents:configure` grant. 3. Call `POST /api/agents/{id}/resume` with the granted agent key. 4. On the base revision, observe a board-only authorization error. **Paperclip version or commit** `master` at `63df7ad2b3`. **Deployment mode** All deployment modes. This is a server authorization and reconcile behavior. ## What Changed - The resume route now uses the protected `agent_config:update` decision with `requiresChangeGrant: true` for agent actors. - The route keeps board access, tenant non-disclosure, and invalid organization-chain protection. - Resume activity now records the real user or agent actor, run, and API key. - Plugin-managed paused agents now receive a stable provenance reason and pause time at creation. - Reconcile backfills only a null reason on an agent that is still declared and stored as paused. - Reconcile preserves manual, budget, system, and other pause reasons. It does not pause a resumed agent again. - The implementation specification now records the narrow resume exception. ## Verification - `pnpm exec vitest run server/src/__tests__/agent-cross-tenant-authz-routes.test.ts server/src/__tests__/plugin-managed-agents.test.ts` passed: 2 files and 26 tests. - `pnpm --filter @paperclipai/server typecheck` passed. - `pnpm -r typecheck` passed. - `pnpm build` passed. - GitHub CI passed all policy, typecheck, build, test, e2e, canary, and security gates on commit `306edf469c`. - Greptile reviewed all 5 changed files. Its check passed with 0 comments and 0 unresolved threads. - The host uses Node 22.22.2. The repository requests Node 24.11 or newer, so pnpm printed engine warnings. - A broad `pnpm test:run` attempt did not complete its general-server group. Runtime port fixtures failed because host port `52000` was already bound. The isolated failing fixture reproduced the same port conflict. The focused feature tests passed before and after the final commit. ## Risks The main risk is an unintended lifecycle permission increase. The change limits agent access to resume only. It requires a protected direct-change decision. It does not open pause, clear-error, terminate, approval, or key-management routes. Tests cover denial, self-denial, tenant isolation, organization-chain checks, and activity attribution. There is no database migration. > This change fixes a narrow gap in the completed plugin, approval, and activity-log roadmap areas. It does not add a new roadmap feature. ## Model Used OpenAI Codex `gpt-5.6-sol`, with xhigh reasoning, tool use, and code execution. The runtime did not expose its context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
63df7ad2b3
commit
ae6761e2b0
|
|
@ -558,7 +558,7 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
|
|||
|---|---|---|
|
||||
| Create company | yes | no |
|
||||
| Hire/create agent | yes (direct) | request via approval |
|
||||
| Pause/resume agent | yes | no |
|
||||
| Pause/resume agent | yes | pause: no; resume: direct `agents:configure` grant only |
|
||||
| Create/update task | yes | yes |
|
||||
| Force reassign task | yes | limited |
|
||||
| Approve strategy/hire requests | yes | no |
|
||||
|
|
@ -569,6 +569,13 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
|
|||
| Manage another user's inbox state | yes | saved target-user opt-in or scoped `inbox:manage` grant |
|
||||
| Set work-object visibility (issue/project) | no | no (pro gate) |
|
||||
|
||||
Agent resume is the only grant-gated exception in the lifecycle-route group. An
|
||||
agent actor calling `POST /agents/:agentId/resume` must pass the protected
|
||||
`agent_config:update` decision with `scope.requiresChangeGrant: true`; self
|
||||
access does not bypass that decision, and `agents:suggest-changes` alone cannot
|
||||
apply the lifecycle change. Pause, clear-error, terminate, approval, and
|
||||
key-management routes remain board-only.
|
||||
|
||||
### 9.3.1 Shared default-open issue writes
|
||||
|
||||
For standard-trust agents, issue comments, issue field/status updates, child
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ vi.mock("../routes/authz.js", async () => {
|
|||
actorId: req.actor.agentId ?? "unknown-agent",
|
||||
agentId: req.actor.agentId ?? null,
|
||||
runId: req.actor.runId ?? null,
|
||||
agentApiKeyId: req.actor.keyId ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -187,6 +188,7 @@ vi.mock("../routes/authz.js", async () => {
|
|||
actorId: req.actor.userId ?? "board",
|
||||
agentId: null,
|
||||
runId: req.actor.runId ?? null,
|
||||
agentApiKeyId: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -437,6 +439,220 @@ describe.sequential("agent cross-tenant route authorization", () => {
|
|||
expect(mockAgentService.clearError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves board resume access", async () => {
|
||||
const pausedAgent = { ...baseAgent, status: "paused", pauseReason: "manual", pausedAt: new Date() };
|
||||
mockAgentService.getById.mockResolvedValue(pausedAgent);
|
||||
mockAgentService.resume.mockResolvedValue({
|
||||
...pausedAgent,
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentService.resume).toHaveBeenCalledWith(agentId);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
agentApiKeyId: null,
|
||||
action: "agent.resumed",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows a same-company agent with a direct agents:configure grant to resume", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
action: "agent_config:update",
|
||||
reason: "allow_direct_change",
|
||||
explanation: "Allowed by direct configuration grant.",
|
||||
grant: { permissionKey: "agents:configure" },
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "44444444-4444-4444-8444-444444444444",
|
||||
companyId,
|
||||
runId: "55555555-5555-4555-8555-555555555555",
|
||||
keyId: "66666666-6666-4666-8666-666666666666",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId, agentId },
|
||||
scope: { requiresChangeGrant: true },
|
||||
}));
|
||||
expect(mockAgentService.resume).toHaveBeenCalledWith(agentId);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an ungranted peer", "44444444-4444-4444-8444-444444444444"],
|
||||
["an ungranted self", agentId],
|
||||
])("denies resume for %s", async (_label, actorAgentId) => {
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: false,
|
||||
action: "agent_config:update",
|
||||
reason: "deny_no_grant",
|
||||
explanation: "No direct agent configuration grant.",
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: actorAgentId,
|
||||
companyId,
|
||||
runId: "55555555-5555-4555-8555-555555555555",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toMatchObject({
|
||||
error: "No direct agent configuration grant.",
|
||||
details: { reason: "deny_no_grant" },
|
||||
});
|
||||
expect(mockAgentService.resume).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies resume when the agent only has agents:suggest-changes", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: false,
|
||||
action: "agent_config:update",
|
||||
reason: "deny_missing_consent",
|
||||
explanation: "Accepted consent is required for this suggested change.",
|
||||
grant: { permissionKey: "agents:suggest-changes" },
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "44444444-4444-4444-8444-444444444444",
|
||||
companyId,
|
||||
runId: "55555555-5555-4555-8555-555555555555",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.details).toEqual({ reason: "deny_missing_consent" });
|
||||
expect(mockAgentService.resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not disclose a cross-company resume target", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "44444444-4444-4444-8444-444444444444",
|
||||
companyId: "77777777-7777-4777-8777-777777777777",
|
||||
runId: "55555555-5555-4555-8555-555555555555",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Agent not found");
|
||||
expect(mockAccessService.decide).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the invalid-org-chain guard for granted agent resume", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
status: "paused",
|
||||
orgChainHealth: {
|
||||
status: "invalid_org_chain",
|
||||
reason: "missing_manager",
|
||||
repairGuidance: "Repair the reporting chain first.",
|
||||
},
|
||||
});
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
action: "agent_config:update",
|
||||
reason: "allow_direct_change",
|
||||
explanation: "Allowed by direct configuration grant.",
|
||||
grant: { permissionKey: "agents:configure" },
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "44444444-4444-4444-8444-444444444444",
|
||||
companyId,
|
||||
runId: "55555555-5555-4555-8555-555555555555",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe("Repair the reporting chain first.");
|
||||
expect(mockAgentService.resume).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attributes agent resume activity to the acting agent, run, and API key", async () => {
|
||||
const actorAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const runId = "55555555-5555-4555-8555-555555555555";
|
||||
const actorKeyId = "66666666-6666-4666-8666-666666666666";
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
action: "agent_config:update",
|
||||
reason: "allow_direct_change",
|
||||
explanation: "Allowed by direct configuration grant.",
|
||||
grant: { permissionKey: "agents:configure" },
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: actorAgentId,
|
||||
companyId,
|
||||
runId,
|
||||
keyId: actorKeyId,
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), {
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: actorAgentId,
|
||||
agentId: actorAgentId,
|
||||
runId,
|
||||
agentApiKeyId: actorKeyId,
|
||||
action: "agent.resumed",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears error agents and records a distinct audit action", async () => {
|
||||
const errorAgent = {
|
||||
...baseAgent,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
|
@ -69,6 +70,15 @@ function manifest(): PaperclipPluginManifestV1 {
|
|||
};
|
||||
}
|
||||
|
||||
function pausedManifest(): PaperclipPluginManifestV1 {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.agents![0] = {
|
||||
...pluginManifest.agents![0]!,
|
||||
status: "paused",
|
||||
};
|
||||
return pluginManifest;
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
|
|
@ -190,6 +200,64 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("records plugin provenance when a manifest creates a paused managed agent", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(created.agent).toMatchObject({
|
||||
status: "paused",
|
||||
pauseReason: "Provisioned paused by plugin paperclip.managed-agents-test; requires explicit activation.",
|
||||
});
|
||||
expect(created.agent?.pausedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("backfills a legacy null pause reason while the managed declaration and agent remain paused", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ pauseReason: null, updatedAt: new Date() })
|
||||
.where(eq(agents.id, created.agentId!));
|
||||
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(reconciled.agent).toMatchObject({
|
||||
status: "paused",
|
||||
pauseReason: "Provisioned paused by plugin paperclip.managed-agents-test; requires explicit activation.",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["manual", "budget", "system", "maintenance"])(
|
||||
"preserves the existing %s pause reason during reconcile",
|
||||
async (pauseReason) => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ pauseReason, updatedAt: new Date() })
|
||||
.where(eq(agents.id, created.agentId!));
|
||||
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(reconciled.agent).toMatchObject({ status: "paused", pauseReason });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an explicit resume durable across managed-agent reconcile", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
await agentService(db).resume(created.agentId!);
|
||||
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(reconciled.agent).toMatchObject({
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates managed agents with the most-used compatible company adapter", async () => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.agents![0] = {
|
||||
|
|
|
|||
|
|
@ -2133,6 +2133,22 @@ export function agentRoutes(
|
|||
);
|
||||
}
|
||||
|
||||
async function assertCanResumeAgent(
|
||||
req: Request,
|
||||
targetAgent: { id: string; companyId: string },
|
||||
) {
|
||||
if (req.actor.type !== "agent") return;
|
||||
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true },
|
||||
});
|
||||
if (decision.allowed) return;
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
function assertNoAgentInstructionsConfigMutation(
|
||||
req: Request,
|
||||
adapterConfig: Record<string, unknown> | null | undefined,
|
||||
|
|
@ -4111,12 +4127,12 @@ export function agentRoutes(
|
|||
});
|
||||
|
||||
router.post("/agents/:id/resume", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleAgent(req, res, id);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
await assertCanResumeAgent(req, existing);
|
||||
if (existing.orgChainHealth?.status === "invalid_org_chain") {
|
||||
res.status(409).json({
|
||||
error: existing.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before resuming it",
|
||||
|
|
@ -4129,10 +4145,14 @@ export function agentRoutes(
|
|||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "agent.resumed",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
|
|
@ -21,6 +21,10 @@ import { agentInstructionsService } from "./agent-instructions.js";
|
|||
const MANAGED_AGENT_ENTITY_TYPE = "managed_agent";
|
||||
const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process";
|
||||
|
||||
function managedAgentPauseReason(pluginKey: string) {
|
||||
return `Provisioned paused by plugin ${pluginKey}; requires explicit activation.`;
|
||||
}
|
||||
|
||||
interface PluginManagedAgentServiceOptions {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
|
|
@ -416,9 +420,12 @@ export function pluginManagedAgentService(
|
|||
|
||||
const requiresApproval = company.requireBoardApprovalForNewAgents;
|
||||
const adapterType = await resolveManagedAdapterType(companyId, declaration);
|
||||
const initialStatus = requiresApproval ? "pending_approval" : declaration.status ?? "idle";
|
||||
let created = await agentSvc.create(companyId, {
|
||||
...declarationPatch(declaration, { adapterType }),
|
||||
status: requiresApproval ? "pending_approval" : declaration.status ?? "idle",
|
||||
status: initialStatus,
|
||||
pauseReason: initialStatus === "paused" ? managedAgentPauseReason(options.pluginKey) : null,
|
||||
pausedAt: initialStatus === "paused" ? new Date() : null,
|
||||
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration),
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
|
|
@ -490,6 +497,57 @@ export function pluginManagedAgentService(
|
|||
return resolution(companyId, declaration, created as Agent, "created", approvalId);
|
||||
}
|
||||
|
||||
async function backfillManagedPauseReason(
|
||||
companyId: string,
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
agent: Agent,
|
||||
) {
|
||||
if (
|
||||
declaration.status !== "paused"
|
||||
|| agent.status !== "paused"
|
||||
|| agent.pauseReason !== null
|
||||
) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({
|
||||
pauseReason: managedAgentPauseReason(options.pluginKey),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(agents.id, agent.id),
|
||||
eq(agents.companyId, companyId),
|
||||
eq(agents.status, "paused"),
|
||||
isNull(agents.pauseReason),
|
||||
))
|
||||
.returning({ id: agents.id })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!updated) {
|
||||
const current = await agentSvc.getById(agent.id) as Agent | null;
|
||||
return current ?? agent;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_agent.pause_reason_backfilled",
|
||||
entityType: "agent",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.agentKey,
|
||||
pauseReason: managedAgentPauseReason(options.pluginKey),
|
||||
},
|
||||
});
|
||||
|
||||
const refreshed = await agentSvc.getById(updated.id) as Agent | null;
|
||||
return refreshed ?? agent;
|
||||
}
|
||||
|
||||
async function get(agentKey: string, companyId: string) {
|
||||
const declaration = declarationFor(agentKey);
|
||||
const binding = await getBinding(companyId, agentKey);
|
||||
|
|
@ -506,15 +564,22 @@ export function pluginManagedAgentService(
|
|||
const declaration = declarationFor(agentKey);
|
||||
const current = await get(agentKey, companyId);
|
||||
if (current.agent) {
|
||||
await upsertBinding(companyId, declaration, current.agent.id);
|
||||
return current;
|
||||
const agent = await backfillManagedPauseReason(companyId, declaration, current.agent);
|
||||
await upsertBinding(companyId, declaration, agent.id);
|
||||
return resolution(companyId, declaration, agent, current.status, current.approvalId);
|
||||
}
|
||||
|
||||
const relinkCandidate = await findRelinkCandidate(companyId, declaration);
|
||||
if (relinkCandidate) {
|
||||
await upsertBinding(companyId, declaration, relinkCandidate.id);
|
||||
const agent = await agentSvc.getById(relinkCandidate.id);
|
||||
return resolution(companyId, declaration, agent as Agent, "relinked");
|
||||
const relinkedAgent = await agentSvc.getById(relinkCandidate.id) as Agent | null;
|
||||
if (!relinkedAgent) throw notFound("Managed agent not found");
|
||||
const agent = await backfillManagedPauseReason(
|
||||
companyId,
|
||||
declaration,
|
||||
relinkedAgent,
|
||||
);
|
||||
return resolution(companyId, declaration, agent, "relinked");
|
||||
}
|
||||
|
||||
return createManagedAgent(companyId, declaration);
|
||||
|
|
|
|||
Loading…
Reference in New Issue