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:
TonioandPaperclip authored and GitHub committed 2026-11-12 01:23:59 +00:00
1 parent 45170b0cac
commit 9dfdeaad86
56 files changed
+1864 -489

No files matched your search

+19 -1
View File
@@ -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")
+4 -3
View File
@@ -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");
+11
View File
@@ -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);
+3 -2
View File
@@ -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),
+1 -1
View File
@@ -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();
+4 -3
View File
@@ -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"); }
+51 -4
View File
@@ -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);