fix: restore managed plugin entry without deleting history

An explicit stock reset now compares the observed configured entry and target revision, preserves earlier entry history, and restores the declared canonical entry atomically. Retained historical entries do not masquerade as stock drift.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 21:34:29 -05:00
1 parent 39af2a875a
commit 5a2c7fc6ff
3 files changed
+72 -29

No files matched your search

@@ -26,6 +26,7 @@ import {
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";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -399,6 +400,18 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
const audit = await db.select().from(activityLog).where(eq(activityLog.action, "agent.instructions_revision_committed"));
expect(audit).toHaveLength(2);
expect(audit.every((row) => row.actorType === "plugin" && row.actorId === pluginId)).toBe(true);
const bundles = agentInstructionsService(db);
await bundles.writeFile(repeated.agent!, "CUSTOM.md", "# Alternate configured entry\n");
const switched = await bundles.updateBundle(repeated.agent!, { entryFile: "CUSTOM.md" });
await agentService(db).update(created.agentId!, { adapterConfig: switched.adapterConfig });
const resetEntry = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(resetEntry.agent?.adapterConfig.instructionsEntryFile).toBe("AGENTS.md");
expect(resetEntry.defaultDrift).toBeNull();
expect(await fs.readFile(instructionsFilePath as string, "utf8")).toBe(content);
const preserved = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!));
expect(preserved).toHaveLength(3);
expect(Buffer.from(preserved.find((row) => row.entryFile === "CUSTOM.md")!.contentBase64, "base64").toString()).toBe("# Alternate configured entry\n");
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
@@ -422,8 +435,10 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
const target = { companyId, agentId: created.agentId! };
const actor = { type: "plugin" as const, pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer" };
const revisions = agentInstructionRevisionService(db);
const baseline = (await revisions.readForPluginReset(target, actor))!;
const input = { ...target, entryFile: "AGENTS.md", baseRevisionId: baseline.revision.id, content: "# Reset\n" };
const baselineRead = await revisions.readForPluginReset(target, actor);
const baseline = baselineRead.snapshot!;
const input = { ...target, entryFile: "AGENTS.md", baseRevisionId: baseline.revision.id,
configuredEntryFile: baselineRead.configuredEntryFile, content: "# Reset\n" };
await expect(revisions.commitPluginReset(input, { ...actor, pluginId: randomUUID() })).rejects.toMatchObject({ status: 403 });
await expect(revisions.commitPluginReset({ ...input, companyId: randomUUID() }, actor)).rejects.toMatchObject({ status: 404 });
await expect(revisions.commitPluginReset(input, { ...actor, agentKey: "foreign-agent" })).rejects.toMatchObject({ status: 403 });
@@ -438,6 +453,13 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
await db.update(pluginManagedResources).set({ resourceId: randomUUID() }).where(eq(pluginManagedResources.id, binding.id));
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
await db.update(pluginManagedResources).set({ resourceId: target.agentId }).where(eq(pluginManagedResources.id, binding.id));
await expect(revisions.commitPluginReset({ ...input, entryFile: "OTHER.md" }, actor)).rejects.toMatchObject({ status: 403 });
const bundles = agentInstructionsService(db);
await bundles.writeFile(created.agent!, "OTHER.md", "# Other entry\n");
const switched = await bundles.updateBundle(created.agent!, { entryFile: "OTHER.md" });
await agentService(db).update(target.agentId, { adapterConfig: switched.adapterConfig });
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 409, details: { code: "INSTRUCTION_ENTRY_CHANGED" } });
await agentService(db).update(target.agentId, { adapterConfig: created.agent!.adapterConfig });
const results = await Promise.allSettled([
revisions.commitPluginReset(input, actor),
revisions.commitPluginReset({ ...input, content: "# Concurrent reset\n" }, actor),
@@ -148,14 +148,17 @@ export function agentInstructionRevisionService(db: Db) {
|| marker.resourceKind !== "agent" || marker.resourceKey !== actor.agentKey) {
throw forbidden("Plugin reset is limited to its declared, bound managed agent");
}
return actor;
const declared = plugin.manifestJson.agents!.find((declaration) => declaration.agentKey === actor.agentKey)!;
return { ...actor, declaredEntryFile: instructionPath(declared.instructions?.entryFile ?? "AGENTS.md") };
}
async function readForPluginReset(target: InstructionTarget, actor: PluginResetActor) {
return db.transaction(async (tx) => {
const state = await lockTarget(tx, target);
await authorizePluginReset(tx, actor, target, state.agent);
const row = await seed(tx, target, state, actor);
return row ? snapshot(row) : null;
const bound = await authorizePluginReset(tx, actor, target, state.agent);
// Preserve the formerly configured entry before an explicit stock reset.
await seed(tx, target, state, bound);
const row = await seed(tx, target, { ...state, entryFile: bound.declaredEntryFile }, bound);
return { snapshot: row ? snapshot(row) : null, configuredEntryFile: state.entryFile };
});
}
async function head(tx: Tx, target: InstructionTarget, entryFile: string) {
@@ -302,6 +305,7 @@ export function agentInstructionRevisionService(db: Db) {
restoreRevisionId: string;
}),
actor: RevisionActor,
configuredEntryFile?: string,
): Promise<AgentInstructionCommitReceipt> {
instructionPath(input.entryFile);
const bytes = "content" in input ? instructionBytes(input.content) : null;
@@ -310,6 +314,11 @@ export function agentInstructionRevisionService(db: Db) {
const bound = actor.type === "plugin"
? await authorizePluginReset(tx, actor, input, state.agent)
: await authorizeInstructionCommit(tx, actor, state.agent);
if (bound.type === "plugin") {
if (input.entryFile !== bound.declaredEntryFile) throw forbidden("Plugin reset must restore its declared entry file");
if (configuredEntryFile !== state.entryFile) throw conflict("Configured instruction entry changed; read the current entry and retry", { code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile });
state.entryFile = input.entryFile;
}
if (state.entryFile !== input.entryFile)
throw conflict(
"Configured instruction entry changed; read the current entry and retry",
@@ -328,10 +337,33 @@ export function agentInstructionRevisionService(db: Db) {
)
: null;
const candidate = bytes ?? Buffer.from(restored!.contentBase64, "base64");
const bindEntry = async () => {
if (
!state.agent.adapterConfig.instructionsRootPath ||
!state.agent.adapterConfig.instructionsBundleMode ||
(bound.type === "plugin" && configuredEntryFile !== input.entryFile)
) {
await tx
.update(agents)
.set({
adapterConfig: {
...state.agent.adapterConfig,
instructionsBundleMode: "managed",
instructionsRootPath: state.root,
instructionsEntryFile: state.entryFile,
instructionsFilePath: `${state.root}/${state.entryFile}`,
},
updatedAt: new Date(),
})
.where(eq(agents.id, state.agent.id));
}
};
// An exact replay can safely return the durable receipt even after its base
// advanced. Restore also deduplicates identical content, preserving history.
if (current && current.contentBase64 === candidate.toString("base64"))
if (current && current.contentBase64 === candidate.toString("base64")) {
await bindEntry();
return { row: current, changed: false };
}
if ((current?.id ?? null) !== input.baseRevisionId) {
throw conflict(
"Instructions changed since the base revision. Read the current entry before saving.",
@@ -354,24 +386,7 @@ export function agentInstructionRevisionService(db: Db) {
input.baseRevisionId,
restored?.id ?? null,
);
if (
!state.agent.adapterConfig.instructionsRootPath ||
!state.agent.adapterConfig.instructionsBundleMode
) {
await tx
.update(agents)
.set({
adapterConfig: {
...state.agent.adapterConfig,
instructionsBundleMode: "managed",
instructionsRootPath: state.root,
instructionsEntryFile: state.entryFile,
instructionsFilePath: `${state.root}/${state.entryFile}`,
},
updatedAt: new Date(),
})
.where(eq(agents.id, state.agent.id));
}
await bindEntry();
return { row, changed: true };
});
let materialization: AgentInstructionCommitReceipt["materialization"] =
@@ -493,8 +508,8 @@ export function agentInstructionRevisionService(db: Db) {
readCommittedForRuntime,
readCurrent,
readForPluginReset,
commitPluginReset: (input: Omit<InstructionCommitInput, "source">, actor: PluginResetActor) =>
commitInternal({ ...input, source: "api" }, actor),
commitPluginReset: (input: Omit<InstructionCommitInput, "source"> & { configuredEntryFile: string }, actor: PluginResetActor) =>
commitInternal({ ...input, source: "api" }, actor, input.configuredEntryFile),
readRevision,
history,
diff,
+8 -2
View File
@@ -2,6 +2,7 @@ import { and, eq, isNull, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
agentInstructionHeads,
companies,
pluginEntities,
pluginManagedResources,
@@ -344,7 +345,7 @@ export function pluginManagedAgentService(
const actor = { type: "plugin" as const, pluginId: options.pluginId, pluginKey: options.pluginKey, agentKey: declaration.agentKey };
const baseline = await revisions.readForPluginReset(target, actor);
const receipt = await revisions.commitPluginReset({ ...target, entryFile: declared.entryFile,
baseRevisionId: baseline?.revision.id ?? null, content: declared.files[declared.entryFile] ?? "" }, actor);
baseRevisionId: baseline.snapshot?.revision.id ?? null, configuredEntryFile: baseline.configuredEntryFile, content: declared.files[declared.entryFile] ?? "" }, actor);
if (receipt.materialization === "pending") throw conflict("Instruction revision saved; retry reset to repair its disk copy", { revisionId: receipt.revision.id });
const refreshed = await agentSvc.getById(agent.id);
if (!refreshed) throw notFound("Managed agent not found");
@@ -352,8 +353,10 @@ export function pluginManagedAgentService(
if (file !== declared.entryFile) await instructions.writeFile(refreshed, file, content);
}
const bundle = await instructions.getBundle(refreshed);
const historicalEntries = new Set((await db.select({ entryFile: agentInstructionHeads.entryFile }).from(agentInstructionHeads)
.where(and(eq(agentInstructionHeads.companyId, companyId), eq(agentInstructionHeads.agentId, agent.id)))).map((row) => row.entryFile));
for (const file of bundle.files) {
if (!file.isEntryFile && !file.virtual && !(file.path in declared.files)) await instructions.deleteFile(refreshed, file.path);
if (!file.isEntryFile && !file.virtual && !historicalEntries.has(file.path) && !(file.path in declared.files)) await instructions.deleteFile(refreshed, file.path);
}
adapterConfig = { ...refreshed.adapterConfig };
delete adapterConfig.promptTemplate;
@@ -394,8 +397,11 @@ export function pluginManagedAgentService(
return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] };
}
const historicalEntries = new Set((await db.select({ entryFile: agentInstructionHeads.entryFile }).from(agentInstructionHeads)
.where(and(eq(agentInstructionHeads.companyId, companyId), eq(agentInstructionHeads.agentId, agent.id)))).map((row) => row.entryFile));
const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]);
const changedFiles = [...paths]
.filter((filePath) => filePath in declared.files || !historicalEntries.has(filePath))
.filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null))
.sort((left, right) => left.localeCompare(right));
if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) {