mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
Refine agent workspaces and organization charts (#20973)
Refine agent details, identity editing, responsive controls, and organization charts with three card tiers, clearer save feedback, and stable filtering. Guard instruction-file renames and root configuration updates in one transaction through the agent lifecycle integration, preserving conflict checks, rollback, and configuration history. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
45170b0cac
commit
9dfdeaad86
56 files changed
+1864
-489
No files matched your search
@@ -114,6 +114,24 @@ describe("on-demand agent avatars", () => {
|
||||
const png = await pool.render(request);
|
||||
expect(await sharp(png).metadata()).toMatchObject({ width: 48, height: 48, format: "png", hasAlpha: true });
|
||||
}, 20_000);
|
||||
it("serves angled portraits separately while keeping the default front cache intact", async () => {
|
||||
const service = createAgentAvatarService(await storage()); cleanups.push(() => service.close());
|
||||
const url = await serve(service);
|
||||
const front = await fetch(url + "?size=256&scale=2");
|
||||
const portrait = await fetch(url + "?size=256&scale=2&view=portrait");
|
||||
expect(portrait.status).toBe(200);
|
||||
expect(portrait.headers.get("etag")).not.toBe(front.headers.get("etag"));
|
||||
await front.arrayBuffer();
|
||||
const bytes = Buffer.from(await portrait.arrayBuffer());
|
||||
expect(await sharp(bytes).metadata()).toMatchObject({ width: 512, height: 512, format: "png", hasAlpha: true });
|
||||
const explicitFront = await fetch(url + "?size=256&scale=2&view=front");
|
||||
expect(explicitFront.headers.get("etag")).toBe(front.headers.get("etag"));
|
||||
await explicitFront.arrayBuffer();
|
||||
const cached = await fetch(url + "?size=256&scale=2&view=portrait", { headers: { "If-None-Match": portrait.headers.get("etag")! } });
|
||||
expect(cached.status).toBe(304);
|
||||
expect(avatarCacheKey({ ...request, view: "portrait" })).not.toBe(avatarCacheKey(request));
|
||||
expect(avatarCacheKey({ ...request, view: "front" })).toBe(avatarCacheKey(request));
|
||||
}, 20_000);
|
||||
it("preserves transparent background pixels in ordinary 512px avatars", async () => {
|
||||
const pool = createAgentAvatarPool(1); cleanups.push(() => pool.close());
|
||||
const png = await pool.render({ ...request, size: 512, scale: 1 });
|
||||
@@ -155,7 +173,7 @@ describe("on-demand agent avatars", () => {
|
||||
await first.arrayBuffer();
|
||||
const second = await fetch(url + "?size=24&scale=2", { headers: { "If-None-Match": first.headers.get("etag")! } });
|
||||
expect(second.status).toBe(304);
|
||||
for (const suffix of ["?size=99999", "?scale=3", "?color=red", "?size=24&size=32"]) {
|
||||
for (const suffix of ["?size=99999", "?scale=3", "?color=red", "?size=24&size=32", "?view=invalid"]) {
|
||||
const invalid = await fetch(url + suffix); expect(invalid.status).toBe(400); await invalid.text();
|
||||
}
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Readable } from "node:stream";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, agentFileRoots, agentInstructionWorkingCopies, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, createDb, closeFileStorageDb } from "@paperclipai/db";
|
||||
import { agents, agentConfigRevisions, agentFileRoots, agentInstructionWorkingCopies, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, createDb, closeFileStorageDb } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentFileStore, adoptAgentFiles, fileHash } from "../services/agent-file-store.js";
|
||||
import { invalidateAgentFileProjection, persistDurableAgentFiles } from "../services/agent-file-durability.js";
|
||||
@@ -89,6 +89,61 @@ describe("durable agent file authority", () => {
|
||||
root = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "File owner", adapterConfig: {} });
|
||||
}
|
||||
|
||||
it("renames the active entry and edited content in the same committed file snapshot", async () => {
|
||||
await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Original"), baseHash: null }, actor());
|
||||
await store().rename({ companyId, agentId, path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("Edited"), baseHash: fileHash(Buffer.from("Original")) }, actor());
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
||||
expect(agent.adapterConfig.instructionsEntryFile).toBe("ROOT.md");
|
||||
const [revision] = await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agentId));
|
||||
expect(revision).toMatchObject({ source: "instructions_rename", createdByUserId: userId,
|
||||
afterConfig: { adapterConfig: { instructionsEntryFile: "ROOT.md" } } });
|
||||
await replaceFilesystem();
|
||||
expect(await store().read(companyId, agentId, "ROOT.md", actor())).toEqual(Buffer.from("Edited"));
|
||||
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a renamed missing root without replacing a concurrently created source", async () => {
|
||||
await store().rename({ companyId, agentId, path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("New root"), baseHash: null }, actor());
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
||||
expect(agent.adapterConfig.instructionsEntryFile).toBe("ROOT.md");
|
||||
expect(await store().read(companyId, agentId, "ROOT.md", actor())).toEqual(Buffer.from("New root"));
|
||||
await expect(store().rename({ companyId, agentId, path: "ROOT.md", renameTo: "OTHER.md", bytes: Buffer.from("Stale draft"), baseHash: null }, actor())).rejects.toMatchObject({ status: 409 });
|
||||
expect(await store().read(companyId, agentId, "OTHER.md", actor())).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a rename that waits behind a concurrent source edit", async () => {
|
||||
await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Original"), baseHash: null }, actor());
|
||||
let enter!: () => void, release!: () => void;
|
||||
const entered = new Promise<void>(resolve => { enter = resolve; });
|
||||
const released = new Promise<void>(resolve => { release = resolve; });
|
||||
const writer = store().locked(companyId, agentId, actor(), true, async (_tx, _agent, folder) => {
|
||||
enter();
|
||||
await released;
|
||||
await materializeInstructionBytes(folder, "AGENTS.md", Buffer.from("Concurrent edit"));
|
||||
});
|
||||
await entered;
|
||||
const rename = store().rename({ companyId, agentId, path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("Original"), baseHash: fileHash(Buffer.from("Original")) }, actor());
|
||||
const rejected = expect(rename).rejects.toMatchObject({ status: 409 });
|
||||
release();
|
||||
await writer;
|
||||
await rejected;
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
||||
expect(agent.adapterConfig.instructionsEntryFile).toBeUndefined();
|
||||
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Concurrent edit"));
|
||||
expect(await store().read(companyId, agentId, "ROOT.md", actor())).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the active root and source when a rename cannot persist its content", async () => {
|
||||
await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Original"), baseHash: null }, actor());
|
||||
objects.rejectWrites = true;
|
||||
await expect(store().rename({ companyId, agentId, path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("Edited"), baseHash: fileHash(Buffer.from("Original")) }, actor())).rejects.toThrow("fixture object-store outage");
|
||||
objects.rejectWrites = false;
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
||||
expect(agent.adapterConfig.instructionsEntryFile).toBeUndefined();
|
||||
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Original"));
|
||||
expect(await store().read(companyId, agentId, "ROOT.md", actor())).toBeNull();
|
||||
});
|
||||
|
||||
it("reads instruction compatibility from the committed head after deleting the entire home", async () => {
|
||||
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Original"));
|
||||
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root,
|
||||
|
||||
@@ -31,9 +31,10 @@ const mockInstructionWorkingCopies = vi.hoisted(() => ({ list: vi.fn(), resolve:
|
||||
vi.mock("../services/agent-instruction-working-copies.js", () => ({ agentInstructionWorkingCopyService: () => mockInstructionWorkingCopies }));
|
||||
|
||||
const mockDownloadAgentFile = vi.hoisted(() => vi.fn());
|
||||
const mockRenameAgentFile = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../services/agent-file-store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-file-store.js")>();
|
||||
return { ...actual, agentFileStore: (...args: Parameters<typeof actual.agentFileStore>) => ({ ...actual.agentFileStore(...args), download: mockDownloadAgentFile }) };
|
||||
return { ...actual, agentFileStore: (...args: Parameters<typeof actual.agentFileStore>) => ({ ...actual.agentFileStore(...args), download: mockDownloadAgentFile, rename: mockRenameAgentFile }) };
|
||||
});
|
||||
|
||||
const mockInstructionRevisions = vi.hoisted(() => ({ readCurrent: vi.fn(), commit: vi.fn(), restore: vi.fn(), history: vi.fn(), readRevision: vi.fn(), diff: vi.fn(), materializeCurrent: vi.fn() }));
|
||||
@@ -581,6 +582,27 @@ describe("agent instructions bundle routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renames through the file store with the authenticated actor and source hash", async () => {
|
||||
mockRenameAgentFile.mockResolvedValue(undefined);
|
||||
const res = await requestApp(await createApp(), baseUrl => request(baseUrl)
|
||||
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
|
||||
.send({ path: "AGENTS.md", renameTo: "ROOT.md", content: "Updated", baseHash: "a".repeat(64) }));
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockRenameAgentFile).toHaveBeenCalledWith({ companyId: "company-1", agentId: "11111111-1111-4111-8111-111111111111",
|
||||
path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("Updated"), baseHash: "a".repeat(64) }, expect.objectContaining({ type: "board" }));
|
||||
expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith(expect.objectContaining({ companyId: "company-1" }), "ROOT.md");
|
||||
expect(mockInstructionRevisions.commit).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a source hash before renaming", async () => {
|
||||
const res = await requestApp(await createApp(), baseUrl => request(baseUrl)
|
||||
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
|
||||
.send({ path: "AGENTS.md", renameTo: "ROOT.md", content: "Updated" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockRenameAgentFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits entry bytes through the canonical service with the authenticated actor", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
|
||||
|
||||
@@ -13,6 +13,7 @@ const requestSchema = z.object({
|
||||
pose: z.enum(CHARACTER_STATES),
|
||||
size: z.string().regex(/^\d+$/).default("512").transform(Number).refine(n => (AGENT_AVATAR_SIZES as readonly number[]).includes(n)),
|
||||
scale: z.enum(["1", "2"]).default("1"),
|
||||
view: z.enum(["front", "portrait"]).default("front"),
|
||||
background: z.enum(["transparent", "paperclip-dark"]).default("transparent"),
|
||||
}).strict();
|
||||
|
||||
@@ -30,14 +31,14 @@ export function agentAvatarRoutes(injected?: ReturnType<typeof createAgentAvatar
|
||||
router.get("/agent-avatars/:version/:palette/:file", async (req, res) => {
|
||||
const file = String(req.params.file);
|
||||
const parsed = requestSchema.safeParse({ ...req.query, version: req.params.version, palette: req.params.palette, pose: file.endsWith(".png") ? file.slice(0, -4) : file });
|
||||
if (!file.endsWith(".png") || !parsed.success || Object.keys(req.query).some(key => !["size", "scale", "background"].includes(key))) {
|
||||
if (!file.endsWith(".png") || !parsed.success || Object.keys(req.query).some(key => !["size", "scale", "background", "view"].includes(key))) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.status(400).json({ error: "Unsupported avatar version, palette, pose, size, or scale" }); return;
|
||||
}
|
||||
const { palette, pose, size, scale, background } = parsed.data;
|
||||
const { palette, pose, size, scale, background, view } = parsed.data;
|
||||
try {
|
||||
service ??= createAgentAvatarService(createStorageProviderFromConfig(loadConfig()));
|
||||
const { stream, byteSize, etag } = await service.get({ appearance: appearanceForPalette(palette === "muted-dream" ? AGENT_PALETTE_IDS[0] : palette), muted: palette === "muted-dream", pose, size: size as AgentAvatarSize, scale: Number(scale) as 1 | 2, background }, req.ip || req.socket.remoteAddress || "unknown");
|
||||
const { stream, byteSize, etag } = await service.get({ appearance: appearanceForPalette(palette === "muted-dream" ? AGENT_PALETTE_IDS[0] : palette), muted: palette === "muted-dream", pose, size: size as AgentAvatarSize, scale: Number(scale) as 1 | 2, background, view }, req.ip || req.socket.remoteAddress || "unknown");
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
res.setHeader("ETag", etag);
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
@@ -5312,6 +5312,17 @@ export function agentRoutes(
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
|
||||
if (!existing) return;
|
||||
if (req.body.renameTo !== undefined) {
|
||||
await assertCanManageInstructionsPath(req, existing);
|
||||
assertExternalInstructionsAdmin(req, existing);
|
||||
if (req.body.baseHash === undefined) throw unprocessable("Read the file and supply baseHash before renaming it");
|
||||
await agentFiles.rename({ companyId: existing.companyId, agentId: existing.id,
|
||||
path: req.body.path, renameTo: req.body.renameTo, bytes: Buffer.from(req.body.content, "utf8"), baseHash: req.body.baseHash }, req.actor);
|
||||
const updated = await svc.getById(existing.id);
|
||||
if (!updated) throw notFound("Agent not found");
|
||||
res.json(await instructions.readFile(updated, req.body.renameTo));
|
||||
return;
|
||||
}
|
||||
const entryFile = deriveBundleState(existing).entryFile;
|
||||
if (instructionPath(req.body.path) === entryFile) {
|
||||
assertExternalInstructionsAdmin(req, existing);
|
||||
|
||||
@@ -3238,7 +3238,7 @@ registry.registerPath({
|
||||
path: "/api/agent-avatars/{version}/{palette}/{file}",
|
||||
tags: ["agents"],
|
||||
summary: "Render or retrieve a public preset agent portrait",
|
||||
description: "On-demand PNG artwork; no agent or company lookup. Logical size determines face detail independently of density. Background defaults to transparent; paperclip-dark supplies the opaque Paperclip dark-mode background for Slack exports. Successful URLs are immutable for one year and return a content-derived ETag. Cache entries regenerate after deletion.",
|
||||
description: "On-demand PNG artwork; no agent or company lookup. Logical size determines face detail independently of density. View defaults to front; portrait tilts the character upward and slightly to the side for larger card artwork. Background defaults to transparent; paperclip-dark supplies the opaque Paperclip dark-mode background for Slack exports. Successful URLs are immutable for one year and return a content-derived ETag. Cache entries regenerate after deletion.",
|
||||
request: {
|
||||
params: z.object({
|
||||
version: z.literal("cap-v1"),
|
||||
@@ -3249,6 +3249,7 @@ registry.registerPath({
|
||||
size: z.enum(AGENT_AVATAR_SIZES.map(String)).optional().default("512"),
|
||||
scale: z.enum(["1", "2"]).optional().default("1"),
|
||||
background: z.enum(["transparent", "paperclip-dark"]).optional().default("transparent"),
|
||||
view: z.enum(["front", "portrait"]).optional().default("front"),
|
||||
}).strict(),
|
||||
},
|
||||
responses: {
|
||||
@@ -3956,7 +3957,7 @@ registry.registerPath({
|
||||
method: "put",
|
||||
path: "/api/agents/{id}/instructions-bundle/file",
|
||||
tags: ["agents"],
|
||||
summary: "Upsert agent instructions file",
|
||||
summary: "Save or rename a managed agent instructions file with a source hash guard",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(upsertAgentInstructionsFileSchema),
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AgentAvatarRequest } from "./agent-avatars.js";
|
||||
|
||||
parentPort!.on("message", async (request: AgentAvatarRequest) => {
|
||||
try {
|
||||
const svg = renderAgentSvg(request.appearance, request.size, request.scale, request.pose, request.muted);
|
||||
const svg = renderAgentSvg(request.appearance, request.size, request.scale, request.pose, request.muted, request.view);
|
||||
const image = sharp(Buffer.from(svg));
|
||||
if (request.background === "paperclip-dark") image.flatten({ background: PAPERCLIP_DARK_AVATAR_BACKGROUND });
|
||||
const png = await image.png().toBuffer();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AgentAppearance, AgentAvatarSize, AgentAvatarBackground, CharacterState } from "@paperclipai/shared";
|
||||
import type { AgentAppearance, AgentAvatarSize, AgentAvatarBackground, AgentAvatarView, CharacterState } from "@paperclipai/shared";
|
||||
import type { StorageProvider } from "../storage/types.js";
|
||||
import { createInviteRateLimiter } from "./invite-rate-limit.js";
|
||||
import { createAgentAvatarPool } from "./agent-avatar-pool.js";
|
||||
@@ -11,10 +11,11 @@ export interface AgentAvatarRequest {
|
||||
pose: CharacterState;
|
||||
muted: boolean;
|
||||
background?: AgentAvatarBackground;
|
||||
view?: AgentAvatarView;
|
||||
}
|
||||
export function avatarCacheKey(request: AgentAvatarRequest) {
|
||||
const { appearance, size, scale, pose, muted, background } = request;
|
||||
return `generated-agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}-${size}-${scale}${background === "paperclip-dark" ? "-paperclip-dark-v1" : ""}.png`;
|
||||
const { appearance, size, scale, pose, muted, background, view } = request;
|
||||
return `generated-agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}-${size}-${scale}${background === "paperclip-dark" ? "-paperclip-dark-v1" : ""}${view === "portrait" ? "-portrait-v1" : ""}.png`;
|
||||
}
|
||||
export class AvatarAdmissionError extends Error {
|
||||
constructor(public readonly retryAfterSeconds: number) { super("Too many cold avatar requests"); }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { extractAgentMentionIds } from "@paperclipai/shared";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
@@ -192,7 +193,7 @@ export async function isAgentFileSaveRecorded(tx: Tx, agent: Agent, relative: st
|
||||
export function agentFileStore(db: Db) {
|
||||
db = fileStorageDb(db);
|
||||
async function locked<T>(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean,
|
||||
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise<T>, texts: string[] = []) {
|
||||
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor, onRollback: (restore: () => Promise<void>) => void) => Promise<T>, texts: string[] = []) {
|
||||
return db.transaction(async tx => {
|
||||
if (write) await assertAgentInteraction(tx, { companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null,
|
||||
operation: "configure", targetAgentId: agentId, lockAgentIds: texts.flatMap(extractAgentMentionIds), lock: "update" });
|
||||
@@ -201,9 +202,15 @@ export function agentFileStore(db: Db) {
|
||||
const bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent);
|
||||
const root = await adoptAgentFiles(tx, agent);
|
||||
if (write) await invalidateAgentFileProjection(root);
|
||||
const result = await fn(tx, agent, root, bound);
|
||||
if (write) await persistDurableAgentFiles(tx, agent, root);
|
||||
return result;
|
||||
let restore: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
const result = await fn(tx, agent, root, bound, callback => { restore = callback; });
|
||||
if (write) await persistDurableAgentFiles(tx, agent, root);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await restore?.();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
async function audit(tx: Tx, agent: Agent, actor: AuthorizationActor, details: Record<string, unknown>) {
|
||||
@@ -231,6 +238,46 @@ export function agentFileStore(db: Db) {
|
||||
},
|
||||
read: (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) =>
|
||||
locked(companyId, agentId, actor, false, (_tx, _agent, root) => readAgentFile(root, instructionPath(relative))),
|
||||
rename: (input: { companyId: string; agentId: string; path: string; renameTo: string; bytes: Buffer; baseHash: string | null }, actor: AuthorizationActor) =>
|
||||
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound, onRollback) => {
|
||||
const source = agentFilePath(input.path);
|
||||
const target = agentFilePath(input.renameTo);
|
||||
if (source === target) throw unprocessable("Choose a different file name");
|
||||
instructionBytes(input.bytes);
|
||||
const previous = await inspectAgentFile(root, source);
|
||||
const isRoot = deriveBundleState(agent).entryFile === source;
|
||||
if ((!previous && !isRoot) || (previous?.hash ?? null) !== input.baseHash) {
|
||||
throw conflict("This file changed since it was read. Reload before renaming.", { code: "AGENT_FILE_CONFLICT", path: source, currentHash: previous?.hash ?? null });
|
||||
}
|
||||
if (previous && !previous.bytes) throw unprocessable("This file is too large to rename in the editor");
|
||||
const snapshot = await scanAgentFiles(root);
|
||||
if ([...snapshot.entries].some(name => name.toLowerCase() === target.toLowerCase())) throw conflict("A file with this name already exists");
|
||||
const newEntries = target.split("/").map((_part, i, parts) => parts.slice(0, i + 1).join("/")).filter(name => !snapshot.entries.has(name)).length;
|
||||
assertDirectorySize(snapshot.size - (previous?.size ?? 0) + input.bytes.length, snapshot.entries.size - (previous ? 1 : 0) + newEntries);
|
||||
await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: bound.type === "agent" ? bound.agentId : null,
|
||||
operation: "mention", texts: [input.bytes.toString("utf8")] });
|
||||
const sourcePath = await assertInstructionPathSafe(root, source);
|
||||
const targetPath = await assertInstructionPathSafe(root, target);
|
||||
onRollback(async () => {
|
||||
if (previous?.bytes) await materializeInstructionBytes(root, source, previous.bytes);
|
||||
await fs.rm(targetPath, { force: true });
|
||||
await invalidateAgentFileProjection(root);
|
||||
});
|
||||
await materializeInstructionBytes(root, target, input.bytes);
|
||||
if (previous) await fs.unlink(sourcePath);
|
||||
if (isRoot) {
|
||||
await updateAgentConfigurationInTransaction(tx as unknown as Db, agent.id, {
|
||||
adapterConfig: { ...agent.adapterConfig, instructionsBundleMode: "managed",
|
||||
instructionsRootPath: root, instructionsEntryFile: target, instructionsFilePath: path.join(root, target) },
|
||||
}, { allowPendingApprovalConfigUpdate: true, recordRevision: {
|
||||
createdByAgentId: bound.type === "agent" ? bound.agentId : null,
|
||||
createdByUserId: bound.type === "board" ? bound.userId : null,
|
||||
source: "instructions_rename",
|
||||
} });
|
||||
}
|
||||
await audit(tx, agent, bound, { path: source, contentHash: null, renamedTo: target });
|
||||
await audit(tx, agent, bound, { path: target, contentHash: fileHash(input.bytes), created: true, renamedFrom: source });
|
||||
}, [input.bytes.toString("utf8")]),
|
||||
write: (input: { companyId: string; agentId: string; path: string; bytes: Buffer | null; baseHash: string | null }, actor: AuthorizationActor) =>
|
||||
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
|
||||
const relative = agentFilePath(input.path);
|
||||
|
||||
Reference in new issue
Block a user