mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
fix(server): guard managed instruction rebuilds
Centralize stock drift detection, staged swaps, and explicit reset backups across every managed-instructions caller. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4683f26c97
commit
af5000fce4
11 files changed
+621
-109
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentInstructionsService } from "../services/agent-instructions.js";
|
||||
|
||||
type TestAgent = {
|
||||
@@ -358,4 +358,164 @@ describe("agent instructions service", () => {
|
||||
]);
|
||||
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
|
||||
});
|
||||
|
||||
it("preserves the whole operator-modified tree during routine materialization", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-drift-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({});
|
||||
|
||||
const initial = await svc.materializeManagedBundle(agent, {
|
||||
"AGENTS.md": "# Stock v1\n",
|
||||
"docs/STOCK.md": "Stock docs\n",
|
||||
});
|
||||
expect(initial.materialization).toMatchObject({ stockStatus: "missing", action: "added" });
|
||||
const managedRoot = initial.bundle.managedRootPath;
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Operator edit\n", "utf8");
|
||||
await fs.writeFile(path.join(managedRoot, "OPERATOR.md"), "Operator-added file\n", "utf8");
|
||||
|
||||
const rebuilt = await svc.materializeManagedBundle(
|
||||
{ ...agent, adapterConfig: initial.adapterConfig },
|
||||
{
|
||||
"AGENTS.md": "# Stock v2\n",
|
||||
"docs/STOCK.md": "Updated stock docs\n",
|
||||
},
|
||||
);
|
||||
|
||||
expect(rebuilt.materialization).toMatchObject({
|
||||
stockStatus: "operator_modified",
|
||||
action: "skipped",
|
||||
skipped: ["AGENTS.md", "OPERATOR.md", "docs/STOCK.md"],
|
||||
backupPath: null,
|
||||
});
|
||||
expect(rebuilt.adapterConfig.instructionsStockHash).toBe(initial.adapterConfig.instructionsStockHash);
|
||||
await expect(fs.readFile(path.join(managedRoot, "AGENTS.md"), "utf8")).resolves.toBe("# Operator edit\n");
|
||||
await expect(fs.readFile(path.join(managedRoot, "OPERATOR.md"), "utf8")).resolves.toBe("Operator-added file\n");
|
||||
await expect(fs.readFile(path.join(managedRoot, "docs", "STOCK.md"), "utf8")).resolves.toBe("Stock docs\n");
|
||||
});
|
||||
|
||||
it("atomically advances a clean stock tree and its recorded hash", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-update-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({});
|
||||
const initial = await svc.materializeManagedBundle(agent, {
|
||||
"AGENTS.md": "# Stock v1\n",
|
||||
"docs/OLD.md": "Old\n",
|
||||
});
|
||||
|
||||
const updated = await svc.materializeManagedBundle(
|
||||
{ ...agent, adapterConfig: initial.adapterConfig },
|
||||
{
|
||||
"AGENTS.md": "# Stock v2\n",
|
||||
"docs/NEW.md": "New\n",
|
||||
},
|
||||
);
|
||||
|
||||
expect(updated.materialization).toMatchObject({
|
||||
stockStatus: "stock_update_available",
|
||||
action: "updated",
|
||||
added: ["docs/NEW.md"],
|
||||
removed: ["docs/OLD.md"],
|
||||
updated: ["AGENTS.md"],
|
||||
backupPath: null,
|
||||
});
|
||||
expect(updated.adapterConfig.instructionsStockHash).toBe(updated.materialization.stockHash);
|
||||
expect(updated.adapterConfig.instructionsStockHash).not.toBe(initial.adapterConfig.instructionsStockHash);
|
||||
await expect(fs.readFile(path.join(updated.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe("# Stock v2\n");
|
||||
await expect(fs.readFile(path.join(updated.bundle.managedRootPath, "docs", "NEW.md"), "utf8")).resolves.toBe("New\n");
|
||||
await expect(fs.stat(path.join(updated.bundle.managedRootPath, "docs", "OLD.md"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("canonicalizes line endings and ignores transient files when classifying drift", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-canonical-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({});
|
||||
const initial = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock\nLine\n" });
|
||||
const managedRoot = initial.bundle.managedRootPath;
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Stock\r\nLine\r\n", "utf8");
|
||||
await fs.writeFile(path.join(managedRoot, ".DS_Store"), "transient", "utf8");
|
||||
|
||||
const unchanged = await svc.materializeManagedBundle(
|
||||
{ ...agent, adapterConfig: initial.adapterConfig },
|
||||
{ "AGENTS.md": "# Stock\nLine\n" },
|
||||
);
|
||||
|
||||
expect(unchanged.materialization).toMatchObject({ stockStatus: "stock_current", action: "unchanged" });
|
||||
await expect(fs.readFile(path.join(managedRoot, "AGENTS.md"), "utf8")).resolves.toBe("# Stock\r\nLine\r\n");
|
||||
await expect(fs.readFile(path.join(managedRoot, ".DS_Store"), "utf8")).resolves.toBe("transient");
|
||||
});
|
||||
|
||||
it("rolls the live tree back when the staged atomic swap fails", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-rollback-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({});
|
||||
const initial = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock v1\n" });
|
||||
const originalRename = fs.rename.bind(fs);
|
||||
const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (source, destination) => {
|
||||
if (path.basename(String(source)) === "staged") throw new Error("simulated swap failure");
|
||||
return originalRename(source, destination);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(svc.materializeManagedBundle(
|
||||
{ ...agent, adapterConfig: initial.adapterConfig },
|
||||
{ "AGENTS.md": "# Stock v2\n" },
|
||||
)).rejects.toThrow("simulated swap failure");
|
||||
} finally {
|
||||
renameSpy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(fs.readFile(path.join(initial.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe("# Stock v1\n");
|
||||
});
|
||||
|
||||
it("backs up operator state before an explicitly requested force reset", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-reset-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
const svc = agentInstructionsService();
|
||||
const agent = makeAgent({});
|
||||
const initial = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock v1\n" });
|
||||
const managedRoot = initial.bundle.managedRootPath;
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Operator edit\n", "utf8");
|
||||
await fs.writeFile(path.join(managedRoot, "OPERATOR.md"), "Keep me\n", "utf8");
|
||||
|
||||
const reset = await svc.forceResetManagedBundle(
|
||||
{ ...agent, adapterConfig: initial.adapterConfig },
|
||||
{ "AGENTS.md": "# Stock v2\n" },
|
||||
);
|
||||
|
||||
expect(reset.materialization).toMatchObject({
|
||||
stockStatus: "operator_modified",
|
||||
action: "reset",
|
||||
backupPath: `${managedRoot}.backup-1`,
|
||||
backedUp: ["AGENTS.md", "OPERATOR.md"],
|
||||
});
|
||||
await expect(fs.readFile(path.join(`${managedRoot}.backup-1`, "AGENTS.md"), "utf8")).resolves.toBe("# Operator edit\n");
|
||||
await expect(fs.readFile(path.join(`${managedRoot}.backup-1`, "OPERATOR.md"), "utf8")).resolves.toBe("Keep me\n");
|
||||
await expect(fs.readFile(path.join(managedRoot, "AGENTS.md"), "utf8")).resolves.toBe("# Stock v2\n");
|
||||
await expect(fs.stat(path.join(managedRoot, "OPERATOR.md"))).rejects.toThrow();
|
||||
|
||||
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Second operator edit\n", "utf8");
|
||||
const secondReset = await svc.forceResetManagedBundle(
|
||||
{ ...agent, adapterConfig: reset.adapterConfig },
|
||||
{ "AGENTS.md": "# Stock v3\n" },
|
||||
);
|
||||
expect(secondReset.materialization.backupPath).toBe(`${managedRoot}.backup-2`);
|
||||
await expect(fs.readFile(path.join(`${managedRoot}.backup-1`, "AGENTS.md"), "utf8")).resolves.toBe("# Operator edit\n");
|
||||
await expect(fs.readFile(path.join(`${managedRoot}.backup-2`, "AGENTS.md"), "utf8")).resolves.toBe(
|
||||
"# Second operator edit\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1084,7 +1084,7 @@ describe.sequential("agent skill routes", () => {
|
||||
"SOUL.md": expect.stringContaining("CEO Persona"),
|
||||
"TOOLS.md": expect.stringContaining("# Tools"),
|
||||
}),
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
{ entryFile: "AGENTS.md" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1110,7 +1110,7 @@ describe.sequential("agent skill routes", () => {
|
||||
expect.objectContaining({
|
||||
"AGENTS.md": expect.stringMatching(/Start actionable work in the same heartbeat\.[\s\S]*Keep the work moving until it is done\./),
|
||||
}),
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
{ entryFile: "AGENTS.md" },
|
||||
);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -797,6 +798,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
const instructions = agentInstructionsService();
|
||||
|
||||
await instructions.writeFile(created.agent!, "AGENTS.md", "# Custom Reflection Coach\n\nOperator edit.\n");
|
||||
await instructions.writeFile(created.agent!, "OPERATOR.md", "Operator-added file.\n");
|
||||
|
||||
const reconciled = await builtIns.ensure(companyId, "reflection-coach");
|
||||
const drift = reconciled.resources.find((resource) => resource.resourceKind === "instructions");
|
||||
@@ -804,12 +806,16 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
stockStatus: "operator_modified",
|
||||
updateAvailable: true,
|
||||
resetAvailable: true,
|
||||
changedFiles: ["AGENTS.md"],
|
||||
changedFiles: ["AGENTS.md", "OPERATOR.md"],
|
||||
});
|
||||
await expect(instructions.readFile(reconciled.agent!, "AGENTS.md")).resolves.toMatchObject({
|
||||
content: "# Custom Reflection Coach\n\nOperator edit.\n",
|
||||
});
|
||||
await expect(instructions.readFile(reconciled.agent!, "OPERATOR.md")).resolves.toMatchObject({
|
||||
content: "Operator-added file.\n",
|
||||
});
|
||||
|
||||
const managedRoot = String(reconciled.agent?.adapterConfig.instructionsRootPath);
|
||||
const reset = await builtIns.reset(companyId, "reflection-coach");
|
||||
expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
|
||||
stockStatus: "stock_current",
|
||||
@@ -818,6 +824,12 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
const resetFile = await instructions.readFile(reset.agent!, "AGENTS.md");
|
||||
expect(resetFile.content).toContain("Reflection Coach");
|
||||
expect(resetFile.content).not.toContain("Operator edit.");
|
||||
await expect(fs.readFile(path.join(`${managedRoot}.backup-1`, "AGENTS.md"), "utf8")).resolves.toBe(
|
||||
"# Custom Reflection Coach\n\nOperator edit.\n",
|
||||
);
|
||||
await expect(fs.readFile(path.join(`${managedRoot}.backup-1`, "OPERATOR.md"), "utf8")).resolves.toBe(
|
||||
"Operator-added file.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks deleting a built-in agent", async () => {
|
||||
|
||||
@@ -454,6 +454,19 @@ describe("company portability", () => {
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: `/tmp/${agent.id}/AGENTS.md`,
|
||||
},
|
||||
materialization: {
|
||||
stockStatus: "missing",
|
||||
action: "added",
|
||||
stockHash: "sha256:stock",
|
||||
previousHash: null,
|
||||
recordedStockHash: null,
|
||||
added: ["AGENTS.md"],
|
||||
removed: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
backedUp: [],
|
||||
backupPath: null,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -3428,7 +3441,6 @@ describe("company portability", () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
clearLegacyPromptTemplate: true,
|
||||
replaceExisting: true,
|
||||
}),
|
||||
);
|
||||
const materializedFiles = agentInstructionsSvc.materializeManagedBundle.mock.calls[0]?.[1] as Record<string, string>;
|
||||
@@ -5213,6 +5225,15 @@ describe("company portability", () => {
|
||||
},
|
||||
});
|
||||
|
||||
agentSvc.list.mockResolvedValue([{
|
||||
id: "agent-1",
|
||||
name: "ClaudeCoder",
|
||||
status: "idle",
|
||||
adapterConfig: {
|
||||
instructionsStockHash: "sha256:previous-stock",
|
||||
},
|
||||
}]);
|
||||
|
||||
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
|
||||
normalized: "updated",
|
||||
});
|
||||
@@ -5222,8 +5243,32 @@ describe("company portability", () => {
|
||||
adapterType: patch.adapterType,
|
||||
adapterConfig: patch.adapterConfig,
|
||||
}));
|
||||
agentInstructionsSvc.materializeManagedBundle.mockImplementationOnce(async (agent: { id: string; adapterConfig: Record<string, unknown> }) => ({
|
||||
bundle: null,
|
||||
adapterConfig: {
|
||||
...agent.adapterConfig,
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: `/tmp/${agent.id}`,
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: `/tmp/${agent.id}/AGENTS.md`,
|
||||
instructionsStockHash: "sha256:previous-stock",
|
||||
},
|
||||
materialization: {
|
||||
stockStatus: "operator_modified",
|
||||
action: "skipped",
|
||||
stockHash: "sha256:imported-stock",
|
||||
previousHash: "sha256:operator-tree",
|
||||
recordedStockHash: "sha256:previous-stock",
|
||||
added: [],
|
||||
removed: ["OPERATOR.md"],
|
||||
updated: ["AGENTS.md"],
|
||||
skipped: ["AGENTS.md", "OPERATOR.md"],
|
||||
backedUp: [],
|
||||
backupPath: null,
|
||||
},
|
||||
}));
|
||||
|
||||
await portability.importBundle({
|
||||
const result = await portability.importBundle({
|
||||
source: {
|
||||
type: "inline",
|
||||
rootPath: exported.rootPath,
|
||||
@@ -5263,8 +5308,22 @@ describe("company portability", () => {
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
normalized: "updated",
|
||||
instructionsStockHash: "sha256:previous-stock",
|
||||
},
|
||||
}));
|
||||
expect(agentInstructionsSvc.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "agent-1",
|
||||
adapterConfig: expect.objectContaining({
|
||||
instructionsStockHash: "sha256:previous-stock",
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
{ clearLegacyPromptTemplate: true },
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
"Preserved operator-modified instructions bundle for claudecoder; imported stock files were skipped.",
|
||||
);
|
||||
});
|
||||
|
||||
it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => {
|
||||
|
||||
@@ -301,6 +301,21 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
expect(content).toContain("You are the LLM Wiki Maintainer.");
|
||||
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
|
||||
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
|
||||
|
||||
const instructionsRoot = path.dirname(instructionsFilePath as string);
|
||||
await fs.writeFile(instructionsFilePath as string, "# Operator plugin edit\n", "utf8");
|
||||
await fs.writeFile(path.join(instructionsRoot, "OPERATOR.md"), "Operator-added plugin file\n", "utf8");
|
||||
await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
await expect(fs.readFile(path.join(`${instructionsRoot}.backup-1`, "AGENTS.md"), "utf8")).resolves.toBe(
|
||||
"# Operator plugin edit\n",
|
||||
);
|
||||
await expect(fs.readFile(path.join(`${instructionsRoot}.backup-1`, "OPERATOR.md"), "utf8")).resolves.toBe(
|
||||
"Operator-added plugin file\n",
|
||||
);
|
||||
await expect(fs.readFile(instructionsFilePath as string, "utf8")).resolves.toContain(
|
||||
"You are the LLM Wiki Maintainer.",
|
||||
);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
|
||||
@@ -1438,7 +1438,7 @@ export function agentRoutes(
|
||||
const materialized = await instructions.materializeManagedBundle(
|
||||
agent,
|
||||
files,
|
||||
{ entryFile: input?.entryFile ?? "AGENTS.md", replaceExisting: false },
|
||||
{ entryFile: input?.entryFile ?? "AGENTS.md" },
|
||||
);
|
||||
const nextAdapterConfig = { ...materialized.adapterConfig };
|
||||
delete nextAdapterConfig.promptTemplate;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { resourceStatus } from "./managed-resource-drift.js";
|
||||
import type { ManagedResourceStockStatus } from "./managed-resource-drift.js";
|
||||
|
||||
const ENTRY_FILE_DEFAULT = "AGENTS.md";
|
||||
const MODE_KEY = "instructionsBundleMode";
|
||||
@@ -9,6 +12,7 @@ const ROOT_KEY = "instructionsRootPath";
|
||||
const ENTRY_KEY = "instructionsEntryFile";
|
||||
const FILE_KEY = "instructionsFilePath";
|
||||
const PROMPT_KEY = "promptTemplate";
|
||||
const STOCK_HASH_KEY = "instructionsStockHash";
|
||||
/** @deprecated Use the managed instructions bundle system instead. */
|
||||
const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate";
|
||||
const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md";
|
||||
@@ -65,6 +69,20 @@ type AgentInstructionsBundle = {
|
||||
files: AgentInstructionsFileSummary[];
|
||||
};
|
||||
|
||||
export type ManagedBundleMaterialization = {
|
||||
stockStatus: ManagedResourceStockStatus;
|
||||
action: "added" | "updated" | "unchanged" | "skipped" | "reset";
|
||||
stockHash: string;
|
||||
previousHash: string | null;
|
||||
recordedStockHash: string | null;
|
||||
added: string[];
|
||||
removed: string[];
|
||||
updated: string[];
|
||||
skipped: string[];
|
||||
backedUp: string[];
|
||||
backupPath: string | null;
|
||||
};
|
||||
|
||||
type BundleState = {
|
||||
config: Record<string, unknown>;
|
||||
mode: BundleMode | null;
|
||||
@@ -191,7 +209,80 @@ async function listFilesRecursive(rootPath: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
await walk(rootPath, "");
|
||||
return output.sort((left, right) => left.localeCompare(right));
|
||||
return output.sort(compareInstructionPaths);
|
||||
}
|
||||
|
||||
function canonicalizeInstructionsContent(content: string) {
|
||||
return content.replace(/\r\n?/g, "\n");
|
||||
}
|
||||
|
||||
function compareInstructionPaths(left: string, right: string) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export function managedInstructionsTreeHash(files: Record<string, string>) {
|
||||
const canonicalEntries = Object.entries(files)
|
||||
.map(([relativePath, content]) => [
|
||||
normalizeRelativeFilePath(relativePath),
|
||||
canonicalizeInstructionsContent(content),
|
||||
] as const)
|
||||
.sort(([left], [right]) => compareInstructionPaths(left, right));
|
||||
const canonicalJson = `{${canonicalEntries
|
||||
.map(([relativePath, content]) => `${JSON.stringify(relativePath)}:${JSON.stringify(content)}`)
|
||||
.join(",")}}`;
|
||||
return `sha256:${createHash("sha256").update(canonicalJson).digest("hex")}`;
|
||||
}
|
||||
|
||||
async function readInstructionsTree(rootPath: string): Promise<Record<string, string> | null> {
|
||||
const rootStat = await statIfExists(rootPath);
|
||||
if (!rootStat) return null;
|
||||
if (!rootStat.isDirectory()) return {};
|
||||
const relativePaths = await listFilesRecursive(rootPath);
|
||||
return Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => [
|
||||
relativePath,
|
||||
await fs.readFile(resolvePathWithinRoot(rootPath, relativePath), "utf8"),
|
||||
] as const)));
|
||||
}
|
||||
|
||||
function normalizeMaterializedFiles(files: Record<string, string>, entryFile: string) {
|
||||
const normalized = new Map<string, string>();
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const normalizedPath = normalizeRelativeFilePath(relativePath);
|
||||
if (normalized.has(normalizedPath)) {
|
||||
throw unprocessable(`Duplicate instructions file path after normalization: ${normalizedPath}`);
|
||||
}
|
||||
normalized.set(normalizedPath, content);
|
||||
}
|
||||
if (!normalized.has(entryFile)) normalized.set(entryFile, "");
|
||||
return Object.fromEntries(
|
||||
[...normalized.entries()].sort(([left], [right]) => compareInstructionPaths(left, right)),
|
||||
);
|
||||
}
|
||||
|
||||
function changedInstructionFiles(current: Record<string, string>, prospective: Record<string, string>) {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const updated: string[] = [];
|
||||
const paths = [...new Set([...Object.keys(current), ...Object.keys(prospective)])]
|
||||
.sort(compareInstructionPaths);
|
||||
for (const relativePath of paths) {
|
||||
const hasCurrent = Object.prototype.hasOwnProperty.call(current, relativePath);
|
||||
const hasProspective = Object.prototype.hasOwnProperty.call(prospective, relativePath);
|
||||
if (!hasCurrent && hasProspective) added.push(relativePath);
|
||||
else if (hasCurrent && !hasProspective) removed.push(relativePath);
|
||||
else if (
|
||||
canonicalizeInstructionsContent(current[relativePath]!)
|
||||
!== canonicalizeInstructionsContent(prospective[relativePath]!)
|
||||
) updated.push(relativePath);
|
||||
}
|
||||
return { added, removed, updated };
|
||||
}
|
||||
|
||||
async function nextInstructionsBackupPath(rootPath: string) {
|
||||
for (let index = 1; ; index += 1) {
|
||||
const candidate = `${rootPath}.backup-${index}`;
|
||||
if (!(await statIfExists(candidate))) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise<AgentInstructionsFileSummary> {
|
||||
@@ -344,7 +435,7 @@ function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructions
|
||||
virtual: true,
|
||||
});
|
||||
}
|
||||
nextFiles.sort((left, right) => left.path.localeCompare(right.path));
|
||||
nextFiles.sort((left, right) => compareInstructionPaths(left.path, right.path));
|
||||
return {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
@@ -381,6 +472,7 @@ function applyBundleConfig(
|
||||
delete next[PROMPT_KEY];
|
||||
delete next[BOOTSTRAP_PROMPT_KEY];
|
||||
}
|
||||
if (input.mode === "external") delete next[STOCK_HASH_KEY];
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -439,6 +531,7 @@ export function syncInstructionsBundleConfigFromFilePath(
|
||||
delete next[MODE_KEY];
|
||||
delete next[ROOT_KEY];
|
||||
delete next[ENTRY_KEY];
|
||||
delete next[STOCK_HASH_KEY];
|
||||
return next;
|
||||
}
|
||||
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, adapterConfig);
|
||||
@@ -682,44 +775,181 @@ export function agentInstructionsService() {
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeManagedBundleInternal(
|
||||
agent: AgentLike,
|
||||
files: Record<string, string>,
|
||||
options?: {
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
entryFile?: string;
|
||||
recordedStockHash?: string | null;
|
||||
forceReset?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
bundle: AgentInstructionsBundle;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
materialization: ManagedBundleMaterialization;
|
||||
}> {
|
||||
const rootPath = resolveManagedInstructionsRoot(agent);
|
||||
const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT;
|
||||
await fs.mkdir(path.dirname(rootPath), { recursive: true });
|
||||
|
||||
const prospectiveFiles = normalizeMaterializedFiles(files, entryFile);
|
||||
const stockHash = managedInstructionsTreeHash(prospectiveFiles);
|
||||
const configuredStockHash = asString(asRecord(agent.adapterConfig)[STOCK_HASH_KEY]);
|
||||
const recordedStockHash = options?.recordedStockHash ?? configuredStockHash;
|
||||
const initialFiles = await readInstructionsTree(rootPath);
|
||||
const initialHash = initialFiles === null ? null : managedInstructionsTreeHash(initialFiles);
|
||||
const initialStatus = resourceStatus({
|
||||
resourceId: initialFiles === null ? null : agent.id,
|
||||
currentHash: initialHash,
|
||||
bindingStockHash: recordedStockHash,
|
||||
latestStockHash: stockHash,
|
||||
});
|
||||
const configuredEntryFile = deriveBundleState(agent).entryFile;
|
||||
const preservedEntryFile = (currentFiles: Record<string, string>) => {
|
||||
if (Object.prototype.hasOwnProperty.call(currentFiles, entryFile)) return entryFile;
|
||||
if (Object.prototype.hasOwnProperty.call(currentFiles, configuredEntryFile)) return configuredEntryFile;
|
||||
if (Object.prototype.hasOwnProperty.call(currentFiles, ENTRY_FILE_DEFAULT)) return ENTRY_FILE_DEFAULT;
|
||||
return Object.keys(currentFiles).sort(compareInstructionPaths)[0] ?? entryFile;
|
||||
};
|
||||
const configFor = (advanceStockHash: boolean, currentFiles: Record<string, string>) => {
|
||||
const next = applyBundleConfig(asRecord(agent.adapterConfig), {
|
||||
mode: "managed",
|
||||
rootPath,
|
||||
entryFile: advanceStockHash ? entryFile : preservedEntryFile(currentFiles),
|
||||
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
|
||||
});
|
||||
if (advanceStockHash) next[STOCK_HASH_KEY] = stockHash;
|
||||
return next;
|
||||
};
|
||||
|
||||
const finish = async (
|
||||
action: ManagedBundleMaterialization["action"],
|
||||
stockStatus: ManagedResourceStockStatus,
|
||||
currentFiles: Record<string, string>,
|
||||
currentHash: string | null,
|
||||
input: { advanceStockHash: boolean; backupPath?: string | null },
|
||||
) => {
|
||||
const changes = changedInstructionFiles(currentFiles, prospectiveFiles);
|
||||
const backupPath = input.backupPath ?? null;
|
||||
const adapterConfig = configFor(input.advanceStockHash, currentFiles);
|
||||
const bundle = await getBundle({ ...agent, adapterConfig });
|
||||
const changedPaths = [...changes.added, ...changes.removed, ...changes.updated]
|
||||
.sort(compareInstructionPaths);
|
||||
return {
|
||||
bundle,
|
||||
adapterConfig,
|
||||
materialization: {
|
||||
stockStatus,
|
||||
action,
|
||||
stockHash,
|
||||
previousHash: currentHash,
|
||||
recordedStockHash,
|
||||
...changes,
|
||||
skipped: action === "skipped" ? changedPaths : [],
|
||||
backedUp: backupPath ? Object.keys(currentFiles).sort(compareInstructionPaths) : [],
|
||||
backupPath,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (!options?.forceReset && initialStatus === "operator_modified") {
|
||||
return finish("skipped", initialStatus, initialFiles ?? {}, initialHash, { advanceStockHash: false });
|
||||
}
|
||||
if (!options?.forceReset && initialStatus === "stock_current") {
|
||||
return finish("unchanged", initialStatus, initialFiles ?? {}, initialHash, { advanceStockHash: true });
|
||||
}
|
||||
|
||||
const stagingRoot = await fs.mkdtemp(path.join(path.dirname(rootPath), ".instructions-materialize-"));
|
||||
const stagedPath = path.join(stagingRoot, "staged");
|
||||
await fs.mkdir(stagedPath, { recursive: true });
|
||||
try {
|
||||
await writeBundleFiles(stagedPath, prospectiveFiles, { overwriteExisting: true });
|
||||
const stagedFiles = await readInstructionsTree(stagedPath);
|
||||
if (!stagedFiles || managedInstructionsTreeHash(stagedFiles) !== stockHash) {
|
||||
throw new Error("Staged instructions bundle hash did not match the prospective stock hash");
|
||||
}
|
||||
|
||||
const recheckedFiles = await readInstructionsTree(rootPath);
|
||||
const recheckedHash = recheckedFiles === null ? null : managedInstructionsTreeHash(recheckedFiles);
|
||||
const recheckedStatus = resourceStatus({
|
||||
resourceId: recheckedFiles === null ? null : agent.id,
|
||||
currentHash: recheckedHash,
|
||||
bindingStockHash: recordedStockHash,
|
||||
latestStockHash: stockHash,
|
||||
});
|
||||
if (!options?.forceReset && recheckedStatus === "operator_modified") {
|
||||
return finish("skipped", recheckedStatus, recheckedFiles ?? {}, recheckedHash, { advanceStockHash: false });
|
||||
}
|
||||
if (!options?.forceReset && recheckedStatus === "stock_current") {
|
||||
return finish("unchanged", recheckedStatus, recheckedFiles ?? {}, recheckedHash, { advanceStockHash: true });
|
||||
}
|
||||
|
||||
const backupPath = options?.forceReset && recheckedFiles !== null
|
||||
? await nextInstructionsBackupPath(rootPath)
|
||||
: null;
|
||||
const displacedPath = recheckedFiles === null
|
||||
? null
|
||||
: backupPath ?? path.join(stagingRoot, "previous");
|
||||
if (displacedPath) {
|
||||
await fs.rename(rootPath, displacedPath);
|
||||
if (!options?.forceReset) {
|
||||
const displacedFiles = await readInstructionsTree(displacedPath);
|
||||
const displacedHash = displacedFiles === null ? null : managedInstructionsTreeHash(displacedFiles);
|
||||
if (displacedHash !== recheckedHash) {
|
||||
await fs.rename(displacedPath, rootPath);
|
||||
return finish("skipped", "operator_modified", displacedFiles ?? {}, displacedHash, {
|
||||
advanceStockHash: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(stagedPath, rootPath);
|
||||
} catch (error) {
|
||||
if (displacedPath && !(await statIfExists(rootPath))) {
|
||||
await fs.rename(displacedPath, rootPath).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const action: ManagedBundleMaterialization["action"] = options?.forceReset
|
||||
? "reset"
|
||||
: recheckedStatus === "missing"
|
||||
? "added"
|
||||
: "updated";
|
||||
return finish(action, recheckedStatus, recheckedFiles ?? {}, recheckedHash, {
|
||||
advanceStockHash: true,
|
||||
backupPath,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeManagedBundle(
|
||||
agent: AgentLike,
|
||||
files: Record<string, string>,
|
||||
options?: {
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
replaceExisting?: boolean;
|
||||
entryFile?: string;
|
||||
recordedStockHash?: string | null;
|
||||
},
|
||||
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
|
||||
const rootPath = resolveManagedInstructionsRoot(agent);
|
||||
const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT;
|
||||
) {
|
||||
return materializeManagedBundleInternal(agent, files, options);
|
||||
}
|
||||
|
||||
if (options?.replaceExisting) {
|
||||
await fs.rm(rootPath, { recursive: true, force: true });
|
||||
}
|
||||
await fs.mkdir(rootPath, { recursive: true });
|
||||
|
||||
const normalizedEntries = Object.entries(files).map(([relativePath, content]) => [
|
||||
normalizeRelativeFilePath(relativePath),
|
||||
content,
|
||||
] as const);
|
||||
for (const [relativePath, content] of normalizedEntries) {
|
||||
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, "utf8");
|
||||
}
|
||||
if (!normalizedEntries.some(([relativePath]) => relativePath === entryFile)) {
|
||||
await fs.writeFile(resolvePathWithinRoot(rootPath, entryFile), "", "utf8");
|
||||
}
|
||||
|
||||
const adapterConfig = applyBundleConfig(asRecord(agent.adapterConfig), {
|
||||
mode: "managed",
|
||||
rootPath,
|
||||
entryFile,
|
||||
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
|
||||
});
|
||||
const bundle = await getBundle({ ...agent, adapterConfig });
|
||||
return { bundle, adapterConfig };
|
||||
async function forceResetManagedBundle(
|
||||
agent: AgentLike,
|
||||
files: Record<string, string>,
|
||||
options?: {
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
entryFile?: string;
|
||||
recordedStockHash?: string | null;
|
||||
},
|
||||
) {
|
||||
return materializeManagedBundleInternal(agent, files, { ...options, forceReset: true });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -731,5 +961,6 @@ export function agentInstructionsService() {
|
||||
exportFiles,
|
||||
ensureManagedBundle: ensureWritableBundle,
|
||||
materializeManagedBundle,
|
||||
forceResetManagedBundle,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { syncRoutineVariablesWithTemplate } from "@paperclipai/shared";
|
||||
import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
|
||||
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { agentInstructionsService } from "./agent-instructions.js";
|
||||
import { agentInstructionsService, managedInstructionsTreeHash } from "./agent-instructions.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { approvalService } from "./approvals.js";
|
||||
import {
|
||||
@@ -936,11 +936,17 @@ export function builtInAgentService(db: Db) {
|
||||
|
||||
async function currentInstructionFiles(agent: Agent, bundle: BuiltInAgentBundleDefinition) {
|
||||
const currentFiles: Record<string, string | null> = {};
|
||||
for (const filePath of Object.keys(bundle.instructions.files)) {
|
||||
const currentBundle = await instructionsSvc.getBundle(agent);
|
||||
const diskFiles = currentBundle.files.filter((file) => !file.virtual);
|
||||
if (diskFiles.length === 0) {
|
||||
for (const filePath of Object.keys(bundle.instructions.files)) currentFiles[filePath] = null;
|
||||
return currentFiles;
|
||||
}
|
||||
for (const file of diskFiles) {
|
||||
try {
|
||||
currentFiles[filePath] = (await instructionsSvc.readFile(agent, filePath)).content;
|
||||
currentFiles[file.path] = (await instructionsSvc.readFile(agent, file.path)).content;
|
||||
} catch {
|
||||
currentFiles[filePath] = null;
|
||||
currentFiles[file.path] = null;
|
||||
}
|
||||
}
|
||||
return currentFiles;
|
||||
@@ -948,10 +954,12 @@ export function builtInAgentService(db: Db) {
|
||||
|
||||
async function materializeInstructions(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") {
|
||||
const bundle = definition.bundle!;
|
||||
const stock = stockHash(bundle.instructions.files);
|
||||
const stock = managedInstructionsTreeHash(bundle.instructions.files);
|
||||
const binding = await getManagedResourceBinding(agent.companyId, definition.key, "instructions", "AGENTS.md");
|
||||
const currentFiles = await currentInstructionFiles(agent, bundle);
|
||||
const currentHash = Object.values(currentFiles).some((value) => value === null) ? null : stockHash(currentFiles);
|
||||
const currentHash = Object.values(currentFiles).some((value) => value === null)
|
||||
? null
|
||||
: managedInstructionsTreeHash(currentFiles as Record<string, string>);
|
||||
const currentState = stockState({
|
||||
resourceKind: "instructions",
|
||||
resourceKey: "AGENTS.md",
|
||||
@@ -963,54 +971,55 @@ export function builtInAgentService(db: Db) {
|
||||
changedFiles: changedFileList(currentFiles, bundle.instructions.files),
|
||||
});
|
||||
|
||||
const shouldWrite =
|
||||
mode === "reset"
|
||||
|| currentState.stockStatus === "missing"
|
||||
|| currentState.stockStatus === "stock_update_available";
|
||||
if (!shouldWrite) {
|
||||
if (!binding && currentHash === stock) {
|
||||
await upsertManagedResourceBinding({
|
||||
companyId: agent.companyId,
|
||||
bundleKey: definition.key,
|
||||
resourceKind: "instructions",
|
||||
resourceKey: "AGENTS.md",
|
||||
resourceId: agent.id,
|
||||
stockVersion: bundle.stockVersion,
|
||||
stockHash: stock,
|
||||
defaultsJson: {
|
||||
entryFile: bundle.instructions.entryFile,
|
||||
files: Object.keys(bundle.instructions.files),
|
||||
},
|
||||
});
|
||||
}
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const materialized = await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, {
|
||||
const materializeOptions = {
|
||||
entryFile: bundle.instructions.entryFile,
|
||||
replaceExisting: true,
|
||||
clearLegacyPromptTemplate: true,
|
||||
});
|
||||
const updated = await agentSvc.update(agent.id, {
|
||||
adapterConfig: materialized.adapterConfig,
|
||||
}, {
|
||||
allowBuiltInAgentMetadata: true,
|
||||
recordRevision: { source: `built-in-bundle:${mode}:instructions` },
|
||||
});
|
||||
if (!updated) throw notFound("Built-in agent not found");
|
||||
await upsertManagedResourceBinding({
|
||||
companyId: agent.companyId,
|
||||
bundleKey: definition.key,
|
||||
resourceKind: "instructions",
|
||||
resourceKey: "AGENTS.md",
|
||||
resourceId: agent.id,
|
||||
stockVersion: bundle.stockVersion,
|
||||
stockHash: stock,
|
||||
defaultsJson: {
|
||||
entryFile: bundle.instructions.entryFile,
|
||||
files: Object.keys(bundle.instructions.files),
|
||||
},
|
||||
});
|
||||
recordedStockHash: binding?.stockHash ?? null,
|
||||
};
|
||||
const materialized = mode === "reset"
|
||||
? await instructionsSvc.forceResetManagedBundle(agent, bundle.instructions.files, materializeOptions)
|
||||
: await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, materializeOptions);
|
||||
if (stableJson(materialized.adapterConfig) !== stableJson(agent.adapterConfig)) {
|
||||
const updated = await agentSvc.update(agent.id, {
|
||||
adapterConfig: materialized.adapterConfig,
|
||||
}, {
|
||||
allowBuiltInAgentMetadata: true,
|
||||
recordRevision: { source: `built-in-bundle:${mode}:instructions` },
|
||||
});
|
||||
if (!updated) throw notFound("Built-in agent not found");
|
||||
}
|
||||
const stockControlled = materialized.materialization.action !== "skipped";
|
||||
if (stockControlled) {
|
||||
await upsertManagedResourceBinding({
|
||||
companyId: agent.companyId,
|
||||
bundleKey: definition.key,
|
||||
resourceKind: "instructions",
|
||||
resourceKey: "AGENTS.md",
|
||||
resourceId: agent.id,
|
||||
stockVersion: bundle.stockVersion,
|
||||
stockHash: stock,
|
||||
defaultsJson: {
|
||||
entryFile: bundle.instructions.entryFile,
|
||||
files: Object.keys(bundle.instructions.files),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (materialized.materialization.action !== "unchanged") {
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "system",
|
||||
actorId: "built-in-agents",
|
||||
action: "built_in_agent.instructions_materialized",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: {
|
||||
key: definition.key,
|
||||
mode,
|
||||
...materialized.materialization,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!stockControlled) return currentState;
|
||||
return stockState({
|
||||
resourceKind: "instructions",
|
||||
resourceKey: "AGENTS.md",
|
||||
@@ -1423,7 +1432,7 @@ export function builtInAgentService(db: Db) {
|
||||
const { routine, triggers } = await getRoutineByBinding(companyId, definition);
|
||||
const proposal = await pendingUpdateProposal(agent);
|
||||
const instructionHash = Object.values(instructionFiles).every((value) => value !== null)
|
||||
? stockHash(instructionFiles)
|
||||
? managedInstructionsTreeHash(instructionFiles as Record<string, string>)
|
||||
: null;
|
||||
const skillHash = skill && Object.values(skillFiles).every((value) => value !== null)
|
||||
? stockHash(skillFiles)
|
||||
@@ -1442,7 +1451,7 @@ export function builtInAgentService(db: Db) {
|
||||
resourceKey: "AGENTS.md",
|
||||
resourceId: agent.id,
|
||||
stockVersion: bundle.stockVersion,
|
||||
latestStockHash: stockHash(bundle.instructions.files),
|
||||
latestStockHash: managedInstructionsTreeHash(bundle.instructions.files),
|
||||
currentHash: instructionHash,
|
||||
bindingStockHash: instructionBinding?.stockHash ?? null,
|
||||
changedFiles: changedFileList(instructionFiles, bundle.instructions.files),
|
||||
|
||||
@@ -2154,6 +2154,7 @@ function normalizePortableConfig(
|
||||
key === "instructionsBundleMode" ||
|
||||
key === "instructionsRootPath" ||
|
||||
key === "instructionsEntryFile" ||
|
||||
key === "instructionsStockHash" ||
|
||||
key === "promptTemplate" ||
|
||||
key === "bootstrapPromptTemplate" || // deprecated — kept for backward compat
|
||||
key === "paperclipSkillSync"
|
||||
@@ -3510,6 +3511,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
delete nextAdapterConfig.instructionsBundleMode;
|
||||
delete nextAdapterConfig.instructionsRootPath;
|
||||
delete nextAdapterConfig.instructionsEntryFile;
|
||||
delete nextAdapterConfig.instructionsStockHash;
|
||||
const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence(
|
||||
companyId,
|
||||
nextAdapterConfig,
|
||||
@@ -5303,6 +5305,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const preImportExistingAgentIds = new Set<string>();
|
||||
const agentStatusById = new Map<string, string | null | undefined>();
|
||||
const existingAgents = await agents.list(targetCompany.id);
|
||||
const existingAgentById = new Map(existingAgents.map((agent) => [agent.id, agent]));
|
||||
for (const existing of existingAgents) {
|
||||
const slug = normalizeAgentUrlKey(existing.name) ?? existing.id;
|
||||
existingSlugToAgentId.set(slug, existing.id);
|
||||
@@ -5418,8 +5421,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
: {};
|
||||
|
||||
if (planAgent.action === "update" && planAgent.existingAgentId) {
|
||||
const existingTarget = existingAgentById.get(planAgent.existingAgentId);
|
||||
const existingTargetConfig = isPlainRecord(existingTarget?.adapterConfig)
|
||||
? existingTarget.adapterConfig
|
||||
: {};
|
||||
const targetInstructionsStockHash = asString(existingTargetConfig.instructionsStockHash);
|
||||
const updateAdapterConfig = { ...patch.adapterConfig };
|
||||
if (targetInstructionsStockHash) {
|
||||
updateAdapterConfig.instructionsStockHash = targetInstructionsStockHash;
|
||||
}
|
||||
let updated = await agents.update(planAgent.existingAgentId, {
|
||||
...patch,
|
||||
adapterConfig: updateAdapterConfig,
|
||||
...automationPausePatch,
|
||||
});
|
||||
if (!updated) {
|
||||
@@ -5436,8 +5449,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
try {
|
||||
const materialized = await instructions.materializeManagedBundle(updated, bundleFiles, {
|
||||
clearLegacyPromptTemplate: true,
|
||||
replaceExisting: true,
|
||||
});
|
||||
if (materialized.materialization.action === "skipped") {
|
||||
warnings.push(`Preserved operator-modified instructions bundle for ${manifestAgent.slug}; imported stock files were skipped.`);
|
||||
}
|
||||
updated = await agents.update(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated;
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -5483,8 +5498,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
try {
|
||||
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
|
||||
clearLegacyPromptTemplate: true,
|
||||
replaceExisting: true,
|
||||
});
|
||||
if (materialized.materialization.action === "skipped") {
|
||||
warnings.push(`Preserved operator-modified instructions bundle for ${manifestAgent.slug}; imported stock files were skipped.`);
|
||||
}
|
||||
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export type ManagedResourceStockStatus =
|
||||
| "missing"
|
||||
| "stock_current"
|
||||
@@ -20,7 +19,6 @@ export function stableJson(value: unknown): string {
|
||||
export function stockHash(value: unknown) {
|
||||
return `sha256:${createHash("sha256").update(stableJson(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function resourceStatus(input: {
|
||||
resourceId: string | null;
|
||||
currentHash: string | null;
|
||||
|
||||
@@ -326,21 +326,19 @@ export function pluginManagedAgentService(
|
||||
companyId: string,
|
||||
agent: Agent,
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
materializeOptions: { replaceExisting: boolean },
|
||||
materializeOptions: { forceReset?: boolean } = {},
|
||||
): Promise<Agent> {
|
||||
const variables = await optionsForInstructionVariables(companyId);
|
||||
const declared = declaredInstructionFiles(declaration, variables);
|
||||
if (!declared) return agent;
|
||||
|
||||
const materialized = await instructions.materializeManagedBundle(
|
||||
agent,
|
||||
declared.files,
|
||||
{
|
||||
entryFile: declared.entryFile,
|
||||
replaceExisting: materializeOptions.replaceExisting,
|
||||
clearLegacyPromptTemplate: true,
|
||||
},
|
||||
);
|
||||
const instructionOptions = {
|
||||
entryFile: declared.entryFile,
|
||||
clearLegacyPromptTemplate: true,
|
||||
};
|
||||
const materialized = materializeOptions.forceReset
|
||||
? await instructions.forceResetManagedBundle(agent, declared.files, instructionOptions)
|
||||
: await instructions.materializeManagedBundle(agent, declared.files, instructionOptions);
|
||||
const updated = await agentSvc.update(agent.id, {
|
||||
adapterConfig: materialized.adapterConfig,
|
||||
}, {
|
||||
@@ -348,6 +346,19 @@ export function pluginManagedAgentService(
|
||||
source: `plugin:${optionsForRevisionSource()}:managed-agent-instructions`,
|
||||
},
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_agent.instructions_materialized",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.agentKey,
|
||||
...materialized.materialization,
|
||||
},
|
||||
});
|
||||
return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig };
|
||||
}
|
||||
|
||||
@@ -423,7 +434,7 @@ export function pluginManagedAgentService(
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
}) as Agent;
|
||||
created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: true });
|
||||
created = await materializeDeclaredInstructions(companyId, created, declaration);
|
||||
|
||||
let approvalId: string | null = null;
|
||||
if (requiresApproval) {
|
||||
@@ -537,7 +548,7 @@ export function pluginManagedAgentService(
|
||||
},
|
||||
});
|
||||
if (!updated) throw notFound("Managed agent not found");
|
||||
const updatedAgent = await materializeDeclaredInstructions(companyId, updated as Agent, declaration, { replaceExisting: true });
|
||||
const updatedAgent = await materializeDeclaredInstructions(companyId, updated as Agent, declaration, { forceReset: true });
|
||||
await upsertBinding(companyId, declaration, updatedAgent.id, {}, adapterType);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
|
||||
Reference in new issue
Block a user