mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix: refresh managed agent ownership after plugin reinstall
Hard uninstall removes plugin bindings but preserves the managed agent and its instruction history. Revalidate the registered stable-key declaration and refresh provenance atomically when relinking, so explicit reset can use its canonical revision authority. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7caea4f8d0
commit
3277a9b7b4
2 files changed
+127
-12
No files matched your search
@@ -27,6 +27,7 @@ import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
|
||||
import { agentInstructionsService } from "../services/agent-instructions.js";
|
||||
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -509,6 +510,78 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
expect(binding?.data).toMatchObject({ agentId });
|
||||
});
|
||||
|
||||
it("preserves canonical history when relinking after a hard uninstall and reinstall", async () => {
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "plugin-reinstall-reset-")));
|
||||
process.env.PAPERCLIP_HOME = tempHome;
|
||||
try {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.agents![0]!.instructions = { content: "# Original stock\n" };
|
||||
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
const target = { companyId, agentId: created.agentId! };
|
||||
const revisions = agentInstructionRevisionService(db);
|
||||
const original = await revisions.readForPluginReset(target, {
|
||||
type: "plugin", pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer",
|
||||
});
|
||||
const registry = pluginRegistryService(db);
|
||||
await registry.uninstall(pluginId, true);
|
||||
expect(await db.select().from(pluginManagedResources)).toHaveLength(0);
|
||||
expect(await db.select().from(pluginEntities)).toHaveLength(0);
|
||||
const nextManifest = structuredClone(pluginManifest);
|
||||
nextManifest.agents![0]!.instructions = { content: "# Reinstalled stock\n" };
|
||||
const installed = await registry.install({ packageName: "@paperclipai/plugin-managed-agents-test" }, nextManifest);
|
||||
expect(installed!.id).not.toBe(pluginId);
|
||||
await registry.updateStatus(installed!.id, { status: "ready" });
|
||||
const reinstalledServices = buildHostServices(db, installed!.id, nextManifest.id, createEventBusStub(), undefined, {
|
||||
manifest: nextManifest,
|
||||
});
|
||||
const relinked = await reinstalledServices.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(relinked.status).toBe("relinked");
|
||||
expect(relinked.agentId).toBe(created.agentId);
|
||||
const reset = await reinstalledServices.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reset.agentId).toBe(created.agentId);
|
||||
expect(reset.agent!.metadata).toMatchObject({ paperclipManagedResource: { pluginId: installed!.id }, pluginManagedAgent: { pluginId: installed!.id } });
|
||||
expect(await fs.readFile(reset.agent!.adapterConfig.instructionsFilePath as string, "utf8")).toBe("# Reinstalled stock\n");
|
||||
const history = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!));
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history.find((row) => row.id === original.snapshot!.revision.id)?.contentBase64).toBe(Buffer.from("# Original stock\n").toString("base64"));
|
||||
expect(history.find((row) => row.id !== original.snapshot!.revision.id)?.parentRevisionId).toBe(original.snapshot!.revision.id);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["not-ready", "capability-removed", "declaration-removed", "other-plugin-owner"])(
|
||||
"does not relink a managed agent when its authority is %s", async (reason) => {
|
||||
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
await db.delete(pluginEntities);
|
||||
await db.delete(pluginManagedResources);
|
||||
if (reason === "not-ready") {
|
||||
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
|
||||
} else if (reason === "capability-removed") {
|
||||
await db.update(plugins).set({ manifestJson: { ...pluginManifest, capabilities: [] } }).where(eq(plugins.id, pluginId));
|
||||
} else if (reason === "declaration-removed") {
|
||||
await db.update(plugins).set({ manifestJson: { ...pluginManifest, agents: [] } }).where(eq(plugins.id, pluginId));
|
||||
} else {
|
||||
const otherId = randomUUID();
|
||||
await db.insert(plugins).values({ id: otherId, pluginKey: "paperclip.other-owner", packageName: "other",
|
||||
version: "0.1.0", apiVersion: 1, categories: [], manifestJson: { ...pluginManifest, id: "paperclip.other-owner" }, status: "ready", installOrder: 2 });
|
||||
await db.update(agents).set({ metadata: { ...created.agent!.metadata,
|
||||
paperclipManagedResource: { pluginId: otherId, pluginKey: pluginManifest.id, resourceKind: "agent", resourceKey: "wiki-maintainer" },
|
||||
} }).where(eq(agents.id, created.agentId!));
|
||||
}
|
||||
const [before] = await db.select().from(agents).where(eq(agents.id, created.agentId!));
|
||||
await expect(services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" })).rejects.toMatchObject({ status: 403 });
|
||||
expect(await db.select().from(pluginEntities)).toHaveLength(0);
|
||||
expect(await db.select().from(pluginManagedResources)).toHaveLength(0);
|
||||
const [after] = await db.select().from(agents).where(eq(agents.id, created.agentId!));
|
||||
expect(after.metadata).toEqual(before.metadata);
|
||||
},
|
||||
);
|
||||
|
||||
it("respects board approval policy for new managed agents", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
companies,
|
||||
pluginEntities,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
activityLog,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -13,7 +15,8 @@ import type {
|
||||
PluginManagedAgentDeclaration,
|
||||
PluginManagedAgentResolution,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { isUuidLike } from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { approvalService } from "./approvals.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
@@ -192,8 +195,8 @@ export function pluginManagedAgentService(
|
||||
return declaration;
|
||||
}
|
||||
|
||||
async function getBinding(companyId: string, agentKey: string) {
|
||||
return db
|
||||
async function getBinding(companyId: string, agentKey: string, client: Pick<Db, "select"> = db) {
|
||||
return client
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(
|
||||
@@ -212,6 +215,7 @@ export function pluginManagedAgentService(
|
||||
agentId: string,
|
||||
extraData: Record<string, unknown> = {},
|
||||
effectiveAdapterType?: string,
|
||||
client: Pick<Db, "select" | "insert" | "update"> = db,
|
||||
) {
|
||||
const adapterType = effectiveAdapterType ?? (await resolveManagedAdapterType(companyId, declaration));
|
||||
const defaultsJson = {
|
||||
@@ -229,7 +233,7 @@ export function pluginManagedAgentService(
|
||||
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||
instructions: declaration.instructions ?? null,
|
||||
};
|
||||
const managedResource = await db
|
||||
const managedResource = await client
|
||||
.select({ id: pluginManagedResources.id })
|
||||
.from(pluginManagedResources)
|
||||
.where(and(
|
||||
@@ -240,12 +244,12 @@ export function pluginManagedAgentService(
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (managedResource) {
|
||||
await db
|
||||
await client
|
||||
.update(pluginManagedResources)
|
||||
.set({ resourceId: agentId, defaultsJson, updatedAt: new Date() })
|
||||
.where(eq(pluginManagedResources.id, managedResource.id));
|
||||
} else {
|
||||
await db.insert(pluginManagedResources).values({
|
||||
await client.insert(pluginManagedResources).values({
|
||||
companyId,
|
||||
pluginId: options.pluginId,
|
||||
pluginKey: options.pluginKey,
|
||||
@@ -267,9 +271,9 @@ export function pluginManagedAgentService(
|
||||
lastReconciledAt: new Date().toISOString(),
|
||||
...extraData,
|
||||
};
|
||||
const existing = await getBinding(companyId, declaration.agentKey);
|
||||
const existing = await getBinding(companyId, declaration.agentKey, client);
|
||||
if (existing) {
|
||||
return db
|
||||
return client
|
||||
.update(pluginEntities)
|
||||
.set({
|
||||
scopeKind: "company",
|
||||
@@ -283,7 +287,7 @@ export function pluginManagedAgentService(
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
return db
|
||||
return client
|
||||
.insert(pluginEntities)
|
||||
.values({
|
||||
pluginId: options.pluginId,
|
||||
@@ -307,6 +311,46 @@ export function pluginManagedAgentService(
|
||||
return rows.find((row) => rowIsManagedAgent(row, options.pluginKey, declaration.agentKey)) ?? null;
|
||||
}
|
||||
|
||||
async function relinkManagedAgent(companyId: string, declaration: PluginManagedAgentDeclaration, agentId: string) {
|
||||
const adapterType = await resolveManagedAdapterType(companyId, declaration);
|
||||
return db.transaction(async (tx) => {
|
||||
const [agent] = await tx.select().from(agents).where(and(
|
||||
eq(agents.id, agentId), eq(agents.companyId, companyId), ne(agents.status, "terminated"),
|
||||
)).for("update");
|
||||
if (!agent || !rowIsManagedAgent(agent, options.pluginKey, declaration.agentKey)) {
|
||||
throw conflict("Managed agent ownership changed before relink");
|
||||
}
|
||||
const [plugin] = await tx.select().from(plugins).where(and(
|
||||
eq(plugins.id, options.pluginId), eq(plugins.pluginKey, options.pluginKey),
|
||||
)).for("share");
|
||||
if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("agents.managed")
|
||||
|| !plugin.manifestJson.agents?.some((entry) => entry.agentKey === declaration.agentKey)) {
|
||||
throw forbidden("Plugin relink is limited to its registered managed agent declaration");
|
||||
}
|
||||
const marker = agent.metadata!.paperclipManagedResource as Record<string, unknown>;
|
||||
const previousPluginId = marker.pluginId;
|
||||
if (typeof previousPluginId !== "string" || !isUuidLike(previousPluginId)) throw forbidden("Managed agent has no plugin owner");
|
||||
if (previousPluginId !== options.pluginId) {
|
||||
const [previousPlugin] = await tx.select({ id: plugins.id }).from(plugins).where(eq(plugins.id, previousPluginId));
|
||||
if (previousPlugin) throw forbidden("Managed agent still belongs to another installed plugin");
|
||||
}
|
||||
// Hard uninstall cascades the bindings, but deliberately leaves the agent
|
||||
// and its canonical history. Transfer only its proven stable-key marker to
|
||||
// the replacement installation, atomically with the new scoped bindings.
|
||||
await upsertBinding(companyId, declaration, agentId, {}, adapterType, tx);
|
||||
const [relinked] = await tx.update(agents).set({
|
||||
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, agent.metadata),
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).returning();
|
||||
await tx.insert(activityLog).values({
|
||||
companyId, actorType: "plugin", actorId: options.pluginId,
|
||||
action: "plugin.managed_agent.relinked", entityType: "agent", entityId: agentId,
|
||||
details: { sourcePluginKey: options.pluginKey, managedResourceKey: declaration.agentKey, previousPluginId },
|
||||
});
|
||||
return relinked as Agent;
|
||||
});
|
||||
}
|
||||
|
||||
async function companyAdapterUsage(companyId: string) {
|
||||
const rows = await db
|
||||
.select({ adapterType: agents.adapterType })
|
||||
@@ -599,9 +643,7 @@ export function pluginManagedAgentService(
|
||||
|
||||
const relinkCandidate = await findRelinkCandidate(companyId, declaration);
|
||||
if (relinkCandidate) {
|
||||
await upsertBinding(companyId, declaration, relinkCandidate.id);
|
||||
const relinkedAgent = await agentSvc.getById(relinkCandidate.id) as Agent | null;
|
||||
if (!relinkedAgent) throw notFound("Managed agent not found");
|
||||
const relinkedAgent = await relinkManagedAgent(companyId, declaration, relinkCandidate.id);
|
||||
const agent = await backfillManagedPauseReason(
|
||||
companyId,
|
||||
declaration,
|
||||
|
||||
Reference in new issue
Block a user