Merge origin/master into fix/issue-1255

- findMentionedAgents: keep normalizeAgentMentionToken + extractAgentMentionIds
- decode @mention tokens with entities.decodeHTMLStrict (full HTML entities)
- Add entities dependency; expand unit tests for Greptile follow-ups

Made-with: Cursor
This commit is contained in:
amit221 2026-03-24 10:03:15 +02:00
commit 53f0988006
334 changed files with 98279 additions and 9577 deletions

View file

@ -0,0 +1,318 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({
getBundle: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
exportFiles: vi.fn(),
ensureManagedBundle: vi.fn(),
materializeManagedBundle: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
heartbeatService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
listAdapterModels: vi.fn(),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", agentRoutes({} as any));
app.use(errorHandler);
return app;
}
function makeAgent() {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
name: "Agent",
role: "engineer",
title: "Engineer",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: null,
updatedAt: new Date(),
};
}
describe("agent instructions bundle routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.getById.mockResolvedValue(makeAgent());
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent(),
adapterConfig: patch.adapterConfig ?? {},
}));
mockAgentInstructionsService.getBundle.mockResolvedValue({
agentId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
mode: "managed",
rootPath: "/tmp/agent-1",
managedRootPath: "/tmp/agent-1",
entryFile: "AGENTS.md",
resolvedEntryPath: "/tmp/agent-1/AGENTS.md",
editable: true,
warnings: [],
legacyPromptTemplateActive: false,
legacyBootstrapPromptTemplateActive: false,
files: [{
path: "AGENTS.md",
size: 12,
language: "markdown",
markdown: true,
isEntryFile: true,
editable: true,
deprecated: false,
virtual: false,
}],
});
mockAgentInstructionsService.readFile.mockResolvedValue({
path: "AGENTS.md",
size: 12,
language: "markdown",
markdown: true,
isEntryFile: true,
editable: true,
deprecated: false,
virtual: false,
content: "# Agent\n",
});
mockAgentInstructionsService.writeFile.mockResolvedValue({
bundle: null,
file: {
path: "AGENTS.md",
size: 18,
language: "markdown",
markdown: true,
isEntryFile: true,
editable: true,
deprecated: false,
virtual: false,
content: "# Updated Agent\n",
},
adapterConfig: {
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
},
});
});
it("returns bundle metadata", async () => {
const res = await request(createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
mode: "managed",
rootPath: "/tmp/agent-1",
managedRootPath: "/tmp/agent-1",
entryFile: "AGENTS.md",
});
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
});
it("writes a bundle file and persists compatibility config", async () => {
const res = await request(createApp())
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
.send({
path: "AGENTS.md",
content: "# Updated Agent\n",
clearLegacyPromptTemplate: true,
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
"AGENTS.md",
"# Updated Agent\n",
{ clearLegacyPromptTemplate: true },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
}),
}),
expect.any(Object),
);
});
it("preserves managed instructions config when switching adapters", async () => {
mockAgentService.getById.mockResolvedValue({
...makeAgent(),
adapterType: "codex_local",
adapterConfig: {
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
model: "gpt-5.4",
},
});
const res = await request(createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
adapterType: "claude_local",
adapterConfig: {
model: "claude-sonnet-4",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterType: "claude_local",
adapterConfig: expect.objectContaining({
model: "claude-sonnet-4",
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
}),
}),
expect.any(Object),
);
});
it("merges same-adapter config patches so instructions metadata is not dropped", async () => {
mockAgentService.getById.mockResolvedValue({
...makeAgent(),
adapterType: "codex_local",
adapterConfig: {
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
model: "gpt-5.4",
},
});
const res = await request(createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
adapterConfig: {
command: "codex --profile engineer",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
command: "codex --profile engineer",
model: "gpt-5.4",
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
}),
}),
expect.any(Object),
);
});
it("replaces adapter config when replaceAdapterConfig is true", async () => {
mockAgentService.getById.mockResolvedValue({
...makeAgent(),
adapterType: "codex_local",
adapterConfig: {
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
model: "gpt-5.4",
},
});
const res = await request(createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
replaceAdapterConfig: true,
adapterConfig: {
command: "codex --profile engineer",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
command: "codex --profile engineer",
}),
}),
expect.any(Object),
);
expect(res.body.adapterConfig).toMatchObject({
command: "codex --profile engineer",
});
expect(res.body.adapterConfig.instructionsBundleMode).toBeUndefined();
expect(res.body.adapterConfig.instructionsRootPath).toBeUndefined();
expect(res.body.adapterConfig.instructionsEntryFile).toBeUndefined();
expect(res.body.adapterConfig.instructionsFilePath).toBeUndefined();
});
});

View file

@ -0,0 +1,361 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { agentInstructionsService } from "../services/agent-instructions.js";
type TestAgent = {
id: string;
companyId: string;
name: string;
adapterConfig: Record<string, unknown>;
};
async function makeTempDir(prefix: string) {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
function makeAgent(adapterConfig: Record<string, unknown>): TestAgent {
return {
id: "agent-1",
companyId: "company-1",
name: "Agent 1",
adapterConfig,
};
}
describe("agent instructions service", () => {
const originalPaperclipHome = process.env.PAPERCLIP_HOME;
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const cleanupDirs = new Set<string>();
afterEach(async () => {
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId;
await Promise.all([...cleanupDirs].map(async (dir) => {
await fs.rm(dir, { recursive: true, force: true });
cleanupDirs.delete(dir);
}));
});
it("copies the existing bundle into the managed root when switching to managed mode", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
const externalRoot = await makeTempDir("paperclip-agent-instructions-external-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(externalRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
await fs.writeFile(path.join(externalRoot, "AGENTS.md"), "# External Agent\n", "utf8");
await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "external",
instructionsRootPath: externalRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(externalRoot, "AGENTS.md"),
});
const result = await svc.updateBundle(agent, { mode: "managed" });
expect(result.bundle.mode).toBe("managed");
expect(result.bundle.managedRootPath).toBe(
path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
),
);
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md", "docs/TOOLS.md"]);
await expect(fs.readFile(path.join(result.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe("# External Agent\n");
await expect(fs.readFile(path.join(result.bundle.managedRootPath, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
});
it("creates the target entry file when switching to a new external root", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
const externalRoot = await makeTempDir("paperclip-agent-instructions-new-external-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(externalRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
});
const result = await svc.updateBundle(agent, {
mode: "external",
rootPath: externalRoot,
entryFile: "docs/AGENTS.md",
});
expect(result.bundle.mode).toBe("external");
expect(result.bundle.rootPath).toBe(externalRoot);
await expect(fs.readFile(path.join(externalRoot, "docs", "AGENTS.md"), "utf8")).resolves.toBe("# Managed Agent\n");
});
it("filters junk files, dependency bundles, and python caches from bundle listings and exports", async () => {
const externalRoot = await makeTempDir("paperclip-agent-instructions-ignore-");
cleanupDirs.add(externalRoot);
await fs.writeFile(path.join(externalRoot, "AGENTS.md"), "# External Agent\n", "utf8");
await fs.writeFile(path.join(externalRoot, ".gitignore"), "node_modules/\n", "utf8");
await fs.writeFile(path.join(externalRoot, ".DS_Store"), "junk", "utf8");
await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
await fs.writeFile(path.join(externalRoot, "docs", "module.pyc"), "compiled", "utf8");
await fs.writeFile(path.join(externalRoot, "docs", "._TOOLS.md"), "appledouble", "utf8");
await fs.mkdir(path.join(externalRoot, "node_modules", "pkg"), { recursive: true });
await fs.writeFile(path.join(externalRoot, "node_modules", "pkg", "index.js"), "export {};\n", "utf8");
await fs.mkdir(path.join(externalRoot, "python", "__pycache__"), { recursive: true });
await fs.writeFile(
path.join(externalRoot, "python", "__pycache__", "module.cpython-313.pyc"),
"compiled",
"utf8",
);
await fs.mkdir(path.join(externalRoot, ".pytest_cache"), { recursive: true });
await fs.writeFile(path.join(externalRoot, ".pytest_cache", "README.md"), "cache", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "external",
instructionsRootPath: externalRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(externalRoot, "AGENTS.md"),
});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.files.map((file) => file.path)).toEqual([".gitignore", "AGENTS.md", "docs/TOOLS.md"]);
expect(Object.keys(exported.files).sort((left, right) => left.localeCompare(right))).toEqual([
".gitignore",
"AGENTS.md",
"docs/TOOLS.md",
]);
});
it("recovers a managed bundle from disk when bundle config metadata is missing", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-recover-");
cleanupDirs.add(paperclipHome);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Recovered Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" });
});
it("prefers the managed bundle on disk when managed metadata points at a stale root", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-stale-managed-");
const staleRoot = await makeTempDir("paperclip-agent-instructions-stale-root-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(staleRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: staleRoot,
instructionsEntryFile: "docs/MISSING.md",
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.entryFile).toBe("AGENTS.md");
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(bundle.warnings).toEqual([
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
]);
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
});
it("heals stale managed metadata when writing bundle files", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-write-");
const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-write-stale-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(staleRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: staleRoot,
instructionsEntryFile: "docs/MISSING.md",
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
});
const result = await svc.writeFile(agent, "docs/TOOLS.md", "## Tools\n");
expect(result.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
});
await expect(fs.readFile(path.join(managedRoot, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
});
it("heals stale managed metadata when deleting bundle files", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-delete-");
const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-delete-stale-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(staleRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
await fs.writeFile(path.join(managedRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: staleRoot,
instructionsEntryFile: "docs/MISSING.md",
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
});
const result = await svc.deleteFile(agent, "docs/TOOLS.md");
expect(result.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
});
await expect(fs.stat(path.join(managedRoot, "docs", "TOOLS.md"))).rejects.toThrow();
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
});
it("recovers the managed bundle when stale root metadata is present but mode is missing", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-partial-managed-");
const staleRoot = await makeTempDir("paperclip-agent-instructions-partial-root-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(staleRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsRootPath: staleRoot,
instructionsEntryFile: "docs/MISSING.md",
});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.entryFile).toBe("AGENTS.md");
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(bundle.warnings).toEqual([
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
]);
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
});
});

View file

@ -76,19 +76,29 @@ const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService,
}));
@ -141,7 +151,26 @@ describe("agent permission routes", () => {
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
bundle: null,
adapterConfig: {
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
instructionsBundleMode: "managed",
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
promptTemplate: files["AGENTS.md"] ?? "",
},
}),
);
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
async (_companyId: string, requested: string[]) => requested,
);
mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config }));
mockLogActivity.mockResolvedValue(undefined);

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
agentSkillEntrySchema,
agentSkillSnapshotSchema,
} from "@paperclipai/shared/validators/adapter-skills";
describe("agent skill contract", () => {
it("accepts optional provenance metadata on skill entries", () => {
expect(agentSkillEntrySchema.parse({
key: "crack-python",
runtimeName: "crack-python",
desired: false,
managed: false,
state: "external",
origin: "user_installed",
originLabel: "User-installed",
locationLabel: "~/.claude/skills",
readOnly: true,
detail: "Installed outside Paperclip management.",
})).toMatchObject({
origin: "user_installed",
locationLabel: "~/.claude/skills",
readOnly: true,
});
});
it("remains backward compatible with snapshots that omit provenance metadata", () => {
expect(agentSkillSnapshotSchema.parse({
adapterType: "claude_local",
supported: true,
mode: "ephemeral",
desiredSkills: [],
entries: [{
key: "paperclipai/paperclip/paperclip",
runtimeName: "paperclip",
desired: true,
managed: true,
state: "configured",
}],
warnings: [],
})).toMatchObject({
adapterType: "claude_local",
entries: [{
key: "paperclipai/paperclip/paperclip",
state: "configured",
}],
});
});
});

View file

@ -0,0 +1,462 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
create: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({}));
const mockHeartbeatService = vi.hoisted(() => ({}));
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockAgentInstructionsService = vi.hoisted(() => ({
getBundle: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
exportFiles: vi.fn(),
ensureManagedBundle: vi.fn(),
materializeManagedBundle: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockAdapter = vi.hoisted(() => ({
listSkills: vi.fn(),
syncSkills: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
listAdapterModels: vi.fn(),
}));
function createDb(requireBoardApprovalForNewAgents = false) {
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(async () => [
{
id: "company-1",
requireBoardApprovalForNewAgents,
},
]),
})),
})),
};
}
function createApp(db: Record<string, unknown> = createDb()) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", agentRoutes(db as any));
app.use(errorHandler);
return app;
}
function makeAgent(adapterType: string) {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
name: "Agent",
role: "engineer",
title: "Engineer",
status: "active",
reportsTo: null,
capabilities: null,
adapterType,
adapterConfig: {},
runtimeConfig: {},
permissions: null,
updatedAt: new Date(),
};
}
describe("agent skill routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent("claude_local"),
});
mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } });
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([
{
key: "paperclipai/paperclip/paperclip",
runtimeName: "paperclip",
source: "/tmp/paperclip",
required: true,
requiredReason: "required",
},
]);
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
async (_companyId: string, requested: string[]) =>
requested.map((value) =>
value === "paperclip"
? "paperclipai/paperclip/paperclip"
: value,
),
);
mockAdapter.listSkills.mockResolvedValue({
adapterType: "claude_local",
supported: true,
mode: "ephemeral",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
mockAdapter.syncSkills.mockResolvedValue({
adapterType: "claude_local",
supported: true,
mode: "ephemeral",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent("claude_local"),
adapterConfig: patch.adapterConfig ?? {},
}));
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
...makeAgent(String(input.adapterType ?? "claude_local")),
...input,
adapterConfig: input.adapterConfig ?? {},
runtimeConfig: input.runtimeConfig ?? {},
budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0),
permissions: null,
}));
mockApprovalService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: input.payload ?? {},
}));
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
bundle: null,
adapterConfig: {
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
instructionsBundleMode: "managed",
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
promptTemplate: files["AGENTS.md"] ?? "",
},
}),
);
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.getMembership.mockResolvedValue(null);
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
});
it("skips runtime materialization when listing Claude skills", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
const res = await request(createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: false,
});
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
expect.objectContaining({
adapterType: "claude_local",
config: expect.objectContaining({
paperclipRuntimeSkills: expect.any(Array),
}),
}),
);
});
it("keeps runtime materialization for persistent skill adapters", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
mockAdapter.listSkills.mockResolvedValue({
adapterType: "codex_local",
supported: true,
mode: "persistent",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
const res = await request(createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: true,
});
});
it("skips runtime materialization when syncing Claude skills", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
const res = await request(createApp())
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: false,
});
expect(mockAdapter.syncSkills).toHaveBeenCalled();
});
it("canonicalizes desired skill references before syncing", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
const res = await request(createApp())
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
.send({ desiredSkills: ["paperclip"] });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
expect(mockAgentService.update).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: expect.objectContaining({
desiredSkills: ["paperclipai/paperclip/paperclip"],
}),
}),
}),
expect.any(Object),
);
});
it("persists canonical desired skills when creating an agent directly", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
desiredSkills: ["paperclip"],
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
expect(mockAgentService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: expect.objectContaining({
desiredSkills: ["paperclipai/paperclip/paperclip"],
}),
}),
}),
);
});
it("materializes a managed AGENTS.md for directly created local agents", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
adapterType: "claude_local",
}),
{ "AGENTS.md": "You are QA." },
{ entryFile: "AGENTS.md", replaceExisting: false },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
}),
}),
);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).not.toMatchObject({
adapterConfig: expect.objectContaining({
promptTemplate: expect.anything(),
}),
});
});
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "CEO",
role: "ceo",
adapterType: "claude_local",
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
role: "ceo",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are the CEO."),
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
"SOUL.md": expect.stringContaining("CEO Persona"),
"TOOLS.md": expect.stringContaining("# Tools"),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
});
it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "Engineer",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
role: "engineer",
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
});
it("includes canonical desired skills in hire approvals", async () => {
const db = createDb(true);
const res = await request(createApp(db))
.post("/api/companies/company-1/agent-hires")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
desiredSkills: ["paperclip"],
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
payload: expect.objectContaining({
desiredSkills: ["paperclipai/paperclip/paperclip"],
requestedConfigurationSnapshot: expect.objectContaining({
desiredSkills: ["paperclipai/paperclip/paperclip"],
}),
}),
}),
);
});
it("uses managed AGENTS config in hire approval payloads", async () => {
const res = await request(createApp(createDb(true)))
.post("/api/companies/company-1/agent-hires")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
payload: expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
}),
}),
}),
);
const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as
| { payload?: { adapterConfig?: Record<string, unknown> } }
| undefined;
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
});
});

View file

@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
function createApp(actorType: "board" | "agent", boardSource: "session" | "local_implicit" = "session") {
function createApp(
actorType: "board" | "agent",
boardSource: "session" | "local_implicit" | "board_key" = "session",
) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -29,11 +32,26 @@ describe("boardMutationGuard", () => {
expect(res.status).toBe(204);
});
it("blocks board mutations without trusted origin", async () => {
const app = createApp("board");
const res = await request(app).post("/mutate").send({ ok: true });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "Board mutation requires trusted browser origin" });
it("blocks board mutations without trusted origin", () => {
const middleware = boardMutationGuard();
const req = {
method: "POST",
actor: { type: "board", userId: "board", source: "session" },
header: () => undefined,
} as any;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as any;
const next = vi.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: "Board mutation requires trusted browser origin",
});
});
it("allows local implicit board mutations without origin", async () => {
@ -42,6 +60,12 @@ describe("boardMutationGuard", () => {
expect(res.status).toBe(204);
});
it("allows board bearer-key mutations without origin", async () => {
const app = createApp("board", "board_key");
const res = await request(app).post("/mutate").send({ ok: true });
expect(res.status).toBe(204);
});
it("allows board mutations from trusted origin", async () => {
const app = createApp("board");
const res = await request(app)
@ -61,8 +85,21 @@ describe("boardMutationGuard", () => {
});
it("does not block authenticated agent mutations", async () => {
const app = createApp("agent");
const res = await request(app).post("/mutate").send({ ok: true });
expect(res.status).toBe(204);
const middleware = boardMutationGuard();
const req = {
method: "POST",
actor: { type: "agent", agentId: "agent-1" },
header: () => undefined,
} as any;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as any;
const next = vi.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,110 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listClaudeSkills,
syncClaudeSkills,
} from "@paperclipai/adapter-claude-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function createSkillDir(root: string, name: string) {
const skillDir = path.join(root, name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
return skillDir;
}
describe("claude local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
const snapshot = await listClaudeSkills({
agentId: "agent-1",
companyId: "company-1",
adapterType: "claude_local",
config: {},
});
expect(snapshot.mode).toBe("ephemeral");
expect(snapshot.supported).toBe(true);
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
});
it("respects an explicit desired skill list without mutating a persistent home", async () => {
const snapshot = await syncClaudeSkills({
agentId: "agent-2",
companyId: "company-1",
adapterType: "claude_local",
config: {
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
}, [paperclipKey]);
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured");
});
it("normalizes legacy flat Paperclip skill refs to canonical keys", async () => {
const snapshot = await listClaudeSkills({
agentId: "agent-3",
companyId: "company-1",
adapterType: "claude_local",
config: {
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
});
expect(snapshot.warnings).toEqual([]);
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.desiredSkills).not.toContain("paperclip");
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
});
it("shows host-level user-installed Claude skills as read-only external entries", async () => {
const home = await makeTempDir("paperclip-claude-user-skills-");
cleanupDirs.add(home);
await createSkillDir(path.join(home, ".claude", "skills"), "crack-python");
const snapshot = await listClaudeSkills({
agentId: "agent-4",
companyId: "company-1",
adapterType: "claude_local",
config: {
env: {
HOME: home,
},
},
});
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: "crack-python",
runtimeName: "crack-python",
state: "external",
managed: false,
origin: "user_installed",
originLabel: "User-installed",
locationLabel: "~/.claude/skills",
readOnly: true,
detail: "Installed outside Paperclip management in the Claude skills home.",
}));
});
});

View file

@ -0,0 +1,230 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockAccessService = vi.hoisted(() => ({
isInstanceAdmin: vi.fn(),
hasPermission: vi.fn(),
canUser: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockBoardAuthService = vi.hoisted(() => ({
createCliAuthChallenge: vi.fn(),
describeCliAuthChallenge: vi.fn(),
approveCliAuthChallenge: vi.fn(),
cancelCliAuthChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
resolveBoardActivityCompanyIds: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
deduplicateAgentName: vi.fn((name: string) => name),
}));
function createApp(actor: any) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
return import("../routes/access.js").then(({ accessRoutes }) =>
import("../middleware/index.js").then(({ errorHandler }) => {
app.use(
"/api",
accessRoutes({} as any, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
})
);
}
describe("cli auth routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a CLI auth challenge with approval metadata", async () => {
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
challenge: {
id: "challenge-1",
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
},
challengeSecret: "pcp_cli_auth_secret",
pendingBoardToken: "pcp_board_token",
});
const app = await createApp({ type: "none", source: "none" });
const res = await request(app)
.post("/api/cli-auth/challenges")
.send({
command: "paperclipai company import",
clientName: "paperclipai cli",
requestedAccess: "board",
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: "challenge-1",
token: "pcp_cli_auth_secret",
boardApiToken: "pcp_board_token",
approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret",
pollPath: "/cli-auth/challenges/challenge-1",
expiresAt: "2026-03-23T13:00:00.000Z",
});
expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret");
});
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
id: "challenge-1",
status: "pending",
command: "paperclipai company import",
clientName: "paperclipai cli",
requestedAccess: "board",
requestedCompanyId: null,
requestedCompanyName: null,
approvedAt: null,
cancelledAt: null,
expiresAt: "2026-03-23T13:00:00.000Z",
approvedByUser: null,
});
const app = await createApp({ type: "none", source: "none" });
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
expect(res.status).toBe(200);
expect(res.body.requiresSignIn).toBe(true);
expect(res.body.canApprove).toBe(false);
});
it("approves a CLI auth challenge for a signed-in board user", async () => {
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
status: "approved",
challenge: {
id: "challenge-1",
boardApiKeyId: "board-key-1",
requestedAccess: "board",
requestedCompanyId: "company-1",
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
},
});
mockBoardAuthService.resolveBoardAccess.mockResolvedValue({
user: { id: "user-1", name: "User One", email: "user@example.com" },
companyIds: ["company-1"],
isInstanceAdmin: false,
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.post("/api/cli-auth/challenges/challenge-1/approve")
.send({ token: "pcp_cli_auth_secret" });
expect(res.status).toBe(200);
expect(res.body).toEqual({
approved: true,
status: "approved",
userId: "user-1",
keyId: "board-key-1",
expiresAt: "2026-03-23T13:00:00.000Z",
});
expect(mockLogActivity).toHaveBeenCalledTimes(1);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "board_api_key.created",
}),
);
});
it("logs approve activity for instance admins without company memberships", async () => {
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
status: "approved",
challenge: {
id: "challenge-2",
boardApiKeyId: "board-key-2",
requestedAccess: "instance_admin_required",
requestedCompanyId: null,
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
},
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
const app = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
});
const res = await request(app)
.post("/api/cli-auth/challenges/challenge-2/approve")
.send({ token: "pcp_cli_auth_secret" });
expect(res.status).toBe(200);
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).toHaveBeenCalledWith({
userId: "admin-1",
requestedCompanyId: null,
boardApiKeyId: "board-key-2",
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("logs revoke activity with resolved audit company ids", async () => {
mockBoardAuthService.assertCurrentBoardKey.mockResolvedValue({
id: "board-key-3",
userId: "admin-2",
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
const app = await createApp({
type: "board",
userId: "admin-2",
keyId: "board-key-3",
source: "board_key",
isInstanceAdmin: true,
companyIds: [],
});
const res = await request(app).post("/api/cli-auth/revoke-current").send({});
expect(res.status).toBe(200);
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).toHaveBeenCalledWith({
userId: "admin-2",
boardApiKeyId: "board-key-3",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-z",
action: "board_api_key.revoked",
}),
);
});
});

View file

@ -117,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
{
kind: "system",
ts,
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
text: "file changes: update /Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx",
},
]);
});

View file

@ -41,6 +41,160 @@ type LogEntry = {
};
describe("codex execute", () => {
it("uses a Paperclip-managed CODEX_HOME outside worktree mode while preserving shared auth and config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-default-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "codex");
const capturePath = path.join(root, "capture.json");
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const managedCodexHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"codex-home",
);
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8");
await writeFakeCodexCommand(commandPath);
const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
const previousCodexHome = process.env.CODEX_HOME;
process.env.HOME = root;
process.env.PAPERCLIP_HOME = paperclipHome;
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.PAPERCLIP_IN_WORKTREE;
process.env.CODEX_HOME = sharedCodexHome;
try {
const logs: LogEntry[] = [];
const result = await execute({
runId: "run-default",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toBe(managedCodexHome);
const managedAuth = path.join(managedCodexHome, "auth.json");
const managedConfig = path.join(managedCodexHome, "config.toml");
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
expect((await fs.lstat(managedConfig)).isFile()).toBe(true);
expect(await fs.readFile(managedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
await expect(fs.lstat(path.join(sharedCodexHome, "companies", "company-1"))).rejects.toThrow();
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
chunk: expect.stringContaining("Using Paperclip-managed Codex home"),
}),
);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("emits a command note that Codex auto-applies repo-scoped AGENTS.md files", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-notes-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "codex");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeCodexCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
let commandNotes: string[] = [];
try {
const result = await execute({
runId: "run-notes",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async (meta) => {
commandNotes = Array.isArray(meta.commandNotes) ? meta.commandNotes : [];
},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
expect(commandNotes).toContain(
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.",
);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
const workspace = path.join(root, "workspace");
@ -48,7 +202,15 @@ describe("codex execute", () => {
const capturePath = path.join(root, "capture.json");
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const isolatedCodexHome = path.join(paperclipHome, "instances", "worktree-1", "codex-home");
const isolatedCodexHome = path.join(
paperclipHome,
"instances",
"worktree-1",
"companies",
"company-1",
"codex-home",
);
const workspaceSkill = path.join(workspace, ".agents", "skills", "paperclip");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
@ -117,13 +279,12 @@ describe("codex execute", () => {
const isolatedAuth = path.join(isolatedCodexHome, "auth.json");
const isolatedConfig = path.join(isolatedCodexHome, "config.toml");
const isolatedSkill = path.join(isolatedCodexHome, "skills", "paperclip");
expect((await fs.lstat(isolatedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
expect((await fs.lstat(isolatedSkill)).isSymbolicLink()).toBe(true);
expect((await fs.lstat(workspaceSkill)).isSymbolicLink()).toBe(true);
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
@ -210,6 +371,7 @@ describe("codex execute", () => {
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toBe(explicitCodexHome);
expect((await fs.lstat(path.join(workspace, ".agents", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.HOME;

View file

@ -31,6 +31,7 @@ async function createCustomSkill(root: string, skillName: string) {
}
describe("codex local adapter skill injection", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
@ -57,7 +58,11 @@ describe("codex local adapter skill injection", () => {
},
{
skillsHome,
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
skillsEntries: [{
key: paperclipKey,
runtimeName: "paperclip",
source: path.join(currentRepo, "skills", "paperclip"),
}],
},
);
@ -86,11 +91,84 @@ describe("codex local adapter skill injection", () => {
await ensureCodexSkillsInjected(async () => {}, {
skillsHome,
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
skillsEntries: [{
key: paperclipKey,
runtimeName: "paperclip",
source: path.join(currentRepo, "skills", "paperclip"),
}],
});
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
await fs.realpath(path.join(customRoot, "custom", "paperclip")),
);
});
it("prunes broken symlinks for unavailable Paperclip repo skills before Codex starts", async () => {
const currentRepo = await makeTempDir("paperclip-codex-current-");
const oldRepo = await makeTempDir("paperclip-codex-old-");
const skillsHome = await makeTempDir("paperclip-codex-home-");
cleanupDirs.add(currentRepo);
cleanupDirs.add(oldRepo);
cleanupDirs.add(skillsHome);
await createPaperclipRepoSkill(currentRepo, "paperclip");
await createPaperclipRepoSkill(oldRepo, "agent-browser");
const staleTarget = path.join(oldRepo, "skills", "agent-browser");
await fs.symlink(staleTarget, path.join(skillsHome, "agent-browser"));
await fs.rm(staleTarget, { recursive: true, force: true });
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
await ensureCodexSkillsInjected(
async (stream, chunk) => {
logs.push({ stream, chunk });
},
{
skillsHome,
skillsEntries: [{
key: paperclipKey,
runtimeName: "paperclip",
source: path.join(currentRepo, "skills", "paperclip"),
}],
},
);
await expect(fs.lstat(path.join(skillsHome, "agent-browser"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
chunk: expect.stringContaining('Removed stale Codex skill "agent-browser"'),
}),
);
});
it("preserves other live Paperclip skill symlinks in the shared workspace skill directory", async () => {
const currentRepo = await makeTempDir("paperclip-codex-current-");
const skillsHome = await makeTempDir("paperclip-codex-home-");
cleanupDirs.add(currentRepo);
cleanupDirs.add(skillsHome);
await createPaperclipRepoSkill(currentRepo, "paperclip");
await createPaperclipRepoSkill(currentRepo, "agent-browser");
await fs.symlink(
path.join(currentRepo, "skills", "agent-browser"),
path.join(skillsHome, "agent-browser"),
);
await ensureCodexSkillsInjected(async () => {}, {
skillsHome,
skillsEntries: [{
key: paperclipKey,
runtimeName: "paperclip",
source: path.join(currentRepo, "skills", "paperclip"),
}],
});
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
expect((await fs.lstat(path.join(skillsHome, "agent-browser"))).isSymbolicLink()).toBe(true);
expect(await fs.realpath(path.join(skillsHome, "agent-browser"))).toBe(
await fs.realpath(path.join(currentRepo, "skills", "agent-browser")),
);
});
});

View file

@ -0,0 +1,122 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listCodexSkills,
syncCodexSkills,
} from "@paperclipai/adapter-codex-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("codex local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("reports configured Paperclip skills for workspace injection on the next run", async () => {
const codexHome = await makeTempDir("paperclip-codex-skill-sync-");
cleanupDirs.add(codexHome);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "codex_local",
config: {
env: {
CODEX_HOME: codexHome,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listCodexSkills(ctx);
expect(before.mode).toBe("ephemeral");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain(".agents/skills");
});
it("does not persist Paperclip skills into CODEX_HOME during sync", async () => {
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
cleanupDirs.add(codexHome);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "codex_local",
config: {
env: {
CODEX_HOME: codexHome,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
const after = await syncCodexSkills(configuredCtx, [paperclipKey]);
expect(after.mode).toBe("ephemeral");
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("keeps required bundled Paperclip skills configured even when the desired set is emptied", async () => {
const codexHome = await makeTempDir("paperclip-codex-skill-required-");
cleanupDirs.add(codexHome);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "codex_local",
config: {
env: {
CODEX_HOME: codexHome,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncCodexSkills(configuredCtx, []);
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
});
it("normalizes legacy flat Paperclip skill refs before reporting configured state", async () => {
const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-");
cleanupDirs.add(codexHome);
const snapshot = await listCodexSkills({
agentId: "agent-3",
companyId: "company-1",
adapterType: "codex_local",
config: {
env: {
CODEX_HOME: codexHome,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
});
expect(snapshot.warnings).toEqual([]);
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.desiredSkills).not.toContain("paperclip");
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
});
});

View file

@ -15,6 +15,7 @@ vi.mock("../services/index.js", () => ({
}),
companyPortabilityService: () => ({
exportBundle: vi.fn(),
previewExport: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}),
@ -25,6 +26,9 @@ vi.mock("../services/index.js", () => ({
budgetService: () => ({
upsertPolicy: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
logActivity: vi.fn(),
}));

View file

@ -0,0 +1,196 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companyRoutes } from "../routes/companies.js";
import { errorHandler } from "../middleware/index.js";
const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(),
stats: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
ensureMembership: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockCompanyPortabilityService = vi.hoisted(() => ({
exportBundle: vi.fn(),
previewExport: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
logActivity: mockLogActivity,
}));
function createCompany() {
const now = new Date("2026-03-19T02:00:00.000Z");
return {
id: "company-1",
name: "Paperclip",
description: null,
status: "active",
issuePrefix: "PAP",
issueCounter: 568,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
requireBoardApprovalForNewAgents: false,
brandColor: "#123456",
logoAssetId: "11111111-1111-4111-8111-111111111111",
logoUrl: "/api/assets/11111111-1111-4111-8111-111111111111/content",
createdAt: now,
updatedAt: now,
};
}
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api/companies", companyRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
mockCompanyService.update.mockReset();
mockAgentService.getById.mockReset();
mockLogActivity.mockReset();
});
it("rejects non-CEO agent callers", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "engineer",
});
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({ logoAssetId: "11111111-1111-4111-8111-111111111111" });
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
it("allows CEO agent callers to update branding fields", async () => {
const company = createCompany();
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "ceo",
});
mockCompanyService.update.mockResolvedValue(company);
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
});
expect(res.status).toBe(200);
expect(res.body.logoAssetId).toBe(company.logoAssetId);
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
agentId: "agent-1",
runId: "run-1",
action: "company.branding_updated",
details: {
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
},
}),
);
});
it("allows board callers to update branding fields", async () => {
const company = createCompany();
mockCompanyService.update.mockResolvedValue({
...company,
brandColor: null,
logoAssetId: null,
logoUrl: null,
});
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({ brandColor: null, logoAssetId: null });
expect(res.status).toBe(200);
expect(res.body.brandColor).toBeNull();
expect(res.body.logoAssetId).toBeNull();
});
it("rejects non-branding fields in the request body", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({
logoAssetId: "11111111-1111-4111-8111-111111111111",
status: "archived",
});
expect(res.status).toBe(400);
expect(res.body.error).toBe("Validation error");
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,175 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(),
stats: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
ensureMembership: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockCompanyPortabilityService = vi.hoisted(() => ({
exportBundle: vi.fn(),
previewExport: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
logActivity: mockLogActivity,
}));
async function createApp(actor: Record<string, unknown>) {
const { companyRoutes } = await import("../routes/companies.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api/companies", companyRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("company portability routes", () => {
beforeEach(() => {
vi.resetModules();
mockAgentService.getById.mockReset();
mockCompanyPortabilityService.exportBundle.mockReset();
mockCompanyPortabilityService.previewExport.mockReset();
mockCompanyPortabilityService.previewImport.mockReset();
mockCompanyPortabilityService.importBundle.mockReset();
mockLogActivity.mockReset();
});
it("rejects non-CEO agents from CEO-safe export preview routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "engineer",
});
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview")
.send({ include: { company: true, agents: true, projects: true } });
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled();
});
it("allows CEO agents to use company-scoped export preview routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "ceo",
});
mockCompanyPortabilityService.previewExport.mockResolvedValue({
rootPath: "paperclip",
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
files: {},
fileInventory: [],
counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 },
warnings: [],
paperclipExtensionPath: ".paperclip.yaml",
});
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview")
.send({ include: { company: true, agents: true, projects: true } });
expect(res.status).toBe(200);
expect(mockCompanyPortabilityService.previewExport).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
include: { company: true, agents: true, projects: true },
});
});
it("rejects replace collision strategy on CEO-safe import routes", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
role: "ceo",
});
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/11111111-1111-4111-8111-111111111111/imports/preview")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
collisionStrategy: "replace",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("does not allow replace");
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it("keeps global import preview routes board-only", async () => {
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/import/preview")
.send({
source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } },
include: { company: true, agents: true, projects: false, issues: false },
target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" },
collisionStrategy: "rename",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Board access required");
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,113 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companySkillRoutes } from "../routes/company-skills.js";
import { errorHandler } from "../middleware/index.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
importFromSource: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", companySkillRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("company skill mutation permissions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCompanySkillService.importFromSource.mockResolvedValue({
imported: [],
warnings: [],
});
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
});
it("allows local board operators to mutate company skills", async () => {
const res = await request(createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
}))
.post("/api/companies/company-1/skills/import")
.send({ source: "https://github.com/vercel-labs/agent-browser" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
);
});
it("blocks same-company agents without management permission from mutating company skills", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
permissions: {},
});
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/companies/company-1/skills/import")
.send({ source: "https://github.com/vercel-labs/agent-browser" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
});
it("allows agents with canCreateAgents to mutate company skills", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
permissions: { canCreateAgents: true },
});
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/companies/company-1/skills/import")
.send({ source: "https://github.com/vercel-labs/agent-browser" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
);
});
});

View file

@ -0,0 +1,229 @@
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverProjectWorkspaceSkillDirectories,
findMissingLocalSkillIds,
normalizeGitHubSkillDirectory,
parseSkillImportSourceInput,
readLocalSkillImportFromDirectory,
} from "../services/company-skills.js";
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
async function makeTempDir(prefix: string) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
cleanupDirs.add(dir);
return dir;
}
async function writeSkillDir(skillDir: string, name: string) {
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n\n# ${name}\n`, "utf8");
}
describe("company skill import source parsing", () => {
it("parses a skills.sh command without executing shell input", () => {
const parsed = parseSkillImportSourceInput(
"npx skills add https://github.com/vercel-labs/skills --skill find-skills",
);
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
expect(parsed.requestedSkillSlug).toBe("find-skills");
expect(parsed.originalSkillsShUrl).toBeNull();
expect(parsed.warnings).toEqual([]);
});
it("parses owner/repo/skill shorthand as skills.sh-managed", () => {
const parsed = parseSkillImportSourceInput("vercel-labs/skills/find-skills");
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
expect(parsed.requestedSkillSlug).toBe("find-skills");
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills/find-skills");
});
it("resolves skills.sh URL with org/repo/skill to GitHub repo and preserves original URL", () => {
const parsed = parseSkillImportSourceInput(
"https://skills.sh/google-labs-code/stitch-skills/design-md",
);
expect(parsed.resolvedSource).toBe("https://github.com/google-labs-code/stitch-skills");
expect(parsed.requestedSkillSlug).toBe("design-md");
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/google-labs-code/stitch-skills/design-md");
});
it("resolves skills.sh URL with org/repo (no skill) to GitHub repo and preserves original URL", () => {
const parsed = parseSkillImportSourceInput(
"https://skills.sh/vercel-labs/skills",
);
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
expect(parsed.requestedSkillSlug).toBeNull();
expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills");
});
it("parses skills.sh commands whose requested skill differs from the folder name", () => {
const parsed = parseSkillImportSourceInput(
"npx skills add https://github.com/remotion-dev/skills --skill remotion-best-practices",
);
expect(parsed.resolvedSource).toBe("https://github.com/remotion-dev/skills");
expect(parsed.requestedSkillSlug).toBe("remotion-best-practices");
expect(parsed.originalSkillsShUrl).toBeNull();
});
it("does not set originalSkillsShUrl for owner/repo shorthand", () => {
const parsed = parseSkillImportSourceInput("vercel-labs/skills");
expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills");
expect(parsed.originalSkillsShUrl).toBeNull();
});
});
describe("project workspace skill discovery", () => {
it("normalizes GitHub skill directories for blob imports and legacy metadata", () => {
expect(normalizeGitHubSkillDirectory("retro/.", "retro")).toBe("retro");
expect(normalizeGitHubSkillDirectory("retro/SKILL.md", "retro")).toBe("retro");
expect(normalizeGitHubSkillDirectory("SKILL.md", "root-skill")).toBe("");
expect(normalizeGitHubSkillDirectory("", "fallback-skill")).toBe("fallback-skill");
});
it("finds bounded skill roots under supported workspace paths", async () => {
const workspace = await makeTempDir("paperclip-skill-workspace-");
await writeSkillDir(workspace, "Workspace Root");
await writeSkillDir(path.join(workspace, "skills", "find-skills"), "Find Skills");
await writeSkillDir(path.join(workspace, ".agents", "skills", "release"), "Release");
await writeSkillDir(path.join(workspace, "skills", ".system", "paperclip"), "Paperclip");
await fs.writeFile(path.join(workspace, "README.md"), "# ignore\n", "utf8");
const discovered = await discoverProjectWorkspaceSkillDirectories({
projectId: "11111111-1111-1111-1111-111111111111",
projectName: "Repo",
workspaceId: "22222222-2222-2222-2222-222222222222",
workspaceName: "Main",
workspaceCwd: workspace,
});
expect(discovered).toEqual([
{ skillDir: path.resolve(workspace), inventoryMode: "project_root" },
{ skillDir: path.resolve(workspace, ".agents", "skills", "release"), inventoryMode: "full" },
{ skillDir: path.resolve(workspace, "skills", ".system", "paperclip"), inventoryMode: "full" },
{ skillDir: path.resolve(workspace, "skills", "find-skills"), inventoryMode: "full" },
]);
});
it("limits root SKILL.md imports to skill-related support folders", async () => {
const workspace = await makeTempDir("paperclip-root-skill-");
await writeSkillDir(workspace, "Workspace Skill");
await fs.mkdir(path.join(workspace, "references"), { recursive: true });
await fs.mkdir(path.join(workspace, "scripts"), { recursive: true });
await fs.mkdir(path.join(workspace, "assets"), { recursive: true });
await fs.mkdir(path.join(workspace, "src"), { recursive: true });
await fs.writeFile(path.join(workspace, "references", "checklist.md"), "# Checklist\n", "utf8");
await fs.writeFile(path.join(workspace, "scripts", "run.sh"), "echo ok\n", "utf8");
await fs.writeFile(path.join(workspace, "assets", "logo.svg"), "<svg />\n", "utf8");
await fs.writeFile(path.join(workspace, "README.md"), "# Repo\n", "utf8");
await fs.writeFile(path.join(workspace, "src", "index.ts"), "export {};\n", "utf8");
const imported = await readLocalSkillImportFromDirectory(
"33333333-3333-4333-8333-333333333333",
workspace,
{ inventoryMode: "project_root", metadata: { sourceKind: "project_scan" } },
);
expect(new Set(imported.fileInventory.map((entry) => entry.path))).toEqual(new Set([
"assets/logo.svg",
"references/checklist.md",
"scripts/run.sh",
"SKILL.md",
]));
expect(imported.fileInventory.map((entry) => entry.kind)).toContain("script");
expect(imported.metadata?.sourceKind).toBe("project_scan");
});
it("parses inline object array items in skill frontmatter metadata", async () => {
const workspace = await makeTempDir("paperclip-inline-skill-yaml-");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
path.join(workspace, "SKILL.md"),
[
"---",
"name: Inline Metadata Skill",
"metadata:",
" sources:",
" - kind: github-dir",
" repo: paperclipai/paperclip",
" path: skills/paperclip",
"---",
"",
"# Inline Metadata Skill",
"",
].join("\n"),
"utf8",
);
const imported = await readLocalSkillImportFromDirectory(
"33333333-3333-4333-8333-333333333333",
workspace,
{ inventoryMode: "full" },
);
expect(imported.metadata).toMatchObject({
sourceKind: "local_path",
sources: [
{
kind: "github-dir",
repo: "paperclipai/paperclip",
path: "skills/paperclip",
},
],
});
});
});
describe("missing local skill reconciliation", () => {
it("flags local-path skills whose directory was removed", async () => {
const workspace = await makeTempDir("paperclip-missing-skill-dir-");
const skillDir = path.join(workspace, "skills", "ghost");
await writeSkillDir(skillDir, "Ghost");
await fs.rm(skillDir, { recursive: true, force: true });
const missingIds = await findMissingLocalSkillIds([
{
id: "skill-1",
sourceType: "local_path",
sourceLocator: skillDir,
},
{
id: "skill-2",
sourceType: "github",
sourceLocator: "https://github.com/vercel-labs/agent-browser",
},
]);
expect(missingIds).toEqual(["skill-1"]);
});
it("flags local-path skills whose SKILL.md file was removed", async () => {
const workspace = await makeTempDir("paperclip-missing-skill-file-");
const skillDir = path.join(workspace, "skills", "ghost");
await writeSkillDir(skillDir, "Ghost");
await fs.rm(path.join(skillDir, "SKILL.md"), { force: true });
const missingIds = await findMissingLocalSkillIds([
{
id: "skill-1",
sourceType: "local_path",
sourceLocator: skillDir,
},
]);
expect(missingIds).toEqual(["skill-1"]);
});
});

View file

@ -46,6 +46,13 @@ type CapturePayload = {
paperclipEnvKeys: string[];
};
async function createSkillDir(root: string, name: string) {
const skillDir = path.join(root, name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
return skillDir;
}
describe("cursor execute", () => {
it("injects paperclip env vars and prompt note by default", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-"));
@ -179,4 +186,77 @@ describe("cursor execute", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("injects company-library runtime skills into the Cursor skills home before execution", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "agent");
const runtimeSkillsRoot = path.join(root, "runtime-skills");
await fs.mkdir(workspace, { recursive: true });
await writeFakeCursorCommand(commandPath);
const paperclipDir = await createSkillDir(runtimeSkillsRoot, "paperclip");
const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart");
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-3",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Cursor Coder",
adapterType: "cursor",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "auto",
paperclipRuntimeSkills: [
{
name: "paperclip",
source: paperclipDir,
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
},
{
name: "ascii-heart",
source: asciiHeartDir,
},
],
paperclipSkillSync: {
desiredSkills: ["ascii-heart"],
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe(
await fs.realpath(asciiHeartDir),
);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,144 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listCursorSkills,
syncCursorSkills,
} from "@paperclipai/adapter-cursor-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function createSkillDir(root: string, name: string) {
const skillDir = path.join(root, name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
return skillDir;
}
describe("cursor local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("reports configured Paperclip skills and installs them into the Cursor skills home", async () => {
const home = await makeTempDir("paperclip-cursor-skill-sync-");
cleanupDirs.add(home);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "cursor",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listCursorSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncCursorSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => {
const home = await makeTempDir("paperclip-cursor-runtime-skills-home-");
const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-");
cleanupDirs.add(home);
cleanupDirs.add(runtimeSkills);
const paperclipDir = await createSkillDir(runtimeSkills, "paperclip");
const asciiHeartDir = await createSkillDir(runtimeSkills, "ascii-heart");
const ctx = {
agentId: "agent-3",
companyId: "company-1",
adapterType: "cursor",
config: {
env: {
HOME: home,
},
paperclipRuntimeSkills: [
{
key: "paperclip",
runtimeName: "paperclip",
source: paperclipDir,
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
},
{
key: "ascii-heart",
runtimeName: "ascii-heart",
source: asciiHeartDir,
},
],
paperclipSkillSync: {
desiredSkills: ["ascii-heart"],
},
},
} as const;
const before = await listCursorSkills(ctx);
expect(before.warnings).toEqual([]);
expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]);
expect(before.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("missing");
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
expect(after.warnings).toEqual([]);
expect(after.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
});
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-cursor-skill-prune-");
cleanupDirs.add(home);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "cursor",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
await syncCursorSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncCursorSkills(clearedCtx, []);
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs";
describe("shouldTrackDevServerPath", () => {
it("ignores repo-local Paperclip state and common test file paths", () => {
expect(
shouldTrackDevServerPath(
".paperclip/worktrees/PAP-712-for-project-configuration-get-rid-of-the-overview-tab-for-now/.agents/skills/paperclip",
),
).toBe(false);
expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false);
expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false);
expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false);
expect(shouldTrackDevServerPath("packages/shared/_tests/helpers.ts")).toBe(false);
expect(shouldTrackDevServerPath("packages/shared/tests/helpers.ts")).toBe(false);
expect(shouldTrackDevServerPath("packages/shared/test/helpers.ts")).toBe(false);
expect(shouldTrackDevServerPath("vitest.config.ts")).toBe(false);
});
it("keeps runtime paths restart-relevant", () => {
expect(shouldTrackDevServerPath("server/src/routes/health.ts")).toBe(true);
expect(shouldTrackDevServerPath("packages/shared/src/index.ts")).toBe(true);
expect(shouldTrackDevServerPath("server/src/testing/runtime.ts")).toBe(true);
});
});

View file

@ -0,0 +1,66 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
const tempDirs = [];
function createTempStatusFile(payload: unknown) {
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-status-"));
tempDirs.push(dir);
const filePath = path.join(dir, "dev-server-status.json");
writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8");
return filePath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("dev server status helpers", () => {
it("reads and normalizes persisted supervisor state", () => {
const filePath = createTempStatusFile({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 4,
changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"],
pendingMigrations: ["0040_restart_banner.sql"],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toEqual({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 4,
changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"],
pendingMigrations: ["0040_restart_banner.sql"],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
});
it("derives waiting-for-idle health state", () => {
const health = toDevServerHealthStatus(
{
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 2,
changedPathsSample: ["server/src/app.ts"],
pendingMigrations: [],
lastRestartAt: "2026-03-20T11:30:00.000Z",
},
{ autoRestartEnabled: true, activeRunCount: 3 },
);
expect(health).toMatchObject({
enabled: true,
restartRequired: true,
reason: "backend_changes",
autoRestartEnabled: true,
activeRunCount: 3,
waitingForIdle: true,
});
});
});

View file

@ -3,6 +3,7 @@ import {
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
@ -142,6 +143,16 @@ describe("execution workspace policy helpers", () => {
});
});
it("maps persisted execution workspace modes back to issue settings", () => {
expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace");
expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch");
expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace");
expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default");
expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default");
expect(issueExecutionWorkspaceModeForPersistedWorkspace(null)).toBe("agent_default");
expect(issueExecutionWorkspaceModeForPersistedWorkspace(undefined)).toBe("agent_default");
});
it("disables project execution workspace policy when the instance flag is off", () => {
expect(
gateProjectExecutionWorkspacePolicy(

View file

@ -27,6 +27,20 @@ console.log(JSON.stringify({
return commandPath;
}
async function writeQuotaGeminiCommand(binDir: string): Promise<string> {
const commandPath = path.join(binDir, "gemini");
const script = `#!/usr/bin/env node
if (process.argv.includes("--help")) {
process.exit(0);
}
console.error("429 RESOURCE_EXHAUSTED: You exceeded your current quota and billing details.");
process.exit(1);
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
return commandPath;
}
describe("gemini_local environment diagnostics", () => {
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
@ -86,6 +100,35 @@ describe("gemini_local environment diagnostics", () => {
expect(args).toContain("gemini-2.5-pro");
expect(args).toContain("--approval-mode");
expect(args).toContain("yolo");
expect(args).toContain("--prompt");
await fs.rm(root, { recursive: true, force: true });
});
it("classifies quota exhaustion as a quota warning instead of a generic failure", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-gemini-local-quota-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const binDir = path.join(root, "bin");
const cwd = path.join(root, "workspace");
await fs.mkdir(binDir, { recursive: true });
await writeQuotaGeminiCommand(binDir);
const result = await testEnvironment({
companyId: "company-1",
adapterType: "gemini_local",
config: {
command: "gemini",
cwd,
env: {
GEMINI_API_KEY: "test-key",
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
});
expect(result.status).toBe("warn");
expect(result.checks.some((check) => check.code === "gemini_hello_probe_quota_exhausted")).toBe(true);
await fs.rm(root, { recursive: true, force: true });
});
});

View file

@ -45,7 +45,7 @@ type CapturePayload = {
};
describe("gemini execute", () => {
it("passes prompt as final argument and injects paperclip env vars", async () => {
it("passes prompt via --prompt and injects paperclip env vars", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
@ -96,10 +96,13 @@ describe("gemini execute", () => {
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--output-format");
expect(capture.argv).toContain("stream-json");
expect(capture.argv).toContain("--prompt");
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat.");
expect(capture.argv.at(-1)).toContain("Paperclip runtime note:");
const promptFlagIndex = capture.argv.indexOf("--prompt");
const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : "";
expect(promptArg).toContain("Follow the paperclip heartbeat.");
expect(promptArg).toContain("Paperclip runtime note:");
expect(capture.paperclipEnvKeys).toEqual(
expect.arrayContaining([
"PAPERCLIP_AGENT_ID",

View file

@ -0,0 +1,89 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listGeminiSkills,
syncGeminiSkills,
} from "@paperclipai/adapter-gemini-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("gemini local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("reports configured Paperclip skills and installs them into the Gemini skills home", async () => {
const home = await makeTempDir("paperclip-gemini-skill-sync-");
cleanupDirs.add(home);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "gemini_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listGeminiSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncGeminiSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-gemini-skill-prune-");
cleanupDirs.add(home);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "gemini_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
await syncGeminiSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncGeminiSkills(clearedCtx, []);
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View file

@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import type { agents } from "@paperclipai/db";
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import {
buildExplicitResumeSessionOverride,
formatRuntimeWorkspaceWarningLog,
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
@ -182,6 +184,57 @@ describe("shouldResetTaskSessionForWake", () => {
});
});
describe("buildExplicitResumeSessionOverride", () => {
it("reuses saved task session params when they belong to the selected failed run", () => {
const result = buildExplicitResumeSessionOverride({
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: "session-before",
resumeRunSessionIdAfter: "session-after",
taskSession: {
sessionParamsJson: {
sessionId: "session-after",
cwd: "/tmp/project",
},
sessionDisplayId: "session-after",
lastRunId: "run-1",
},
sessionCodec: codexSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "session-after",
sessionParams: {
sessionId: "session-after",
cwd: "/tmp/project",
},
});
});
it("falls back to the selected run session id when no matching task session params are available", () => {
const result = buildExplicitResumeSessionOverride({
resumeFromRunId: "run-1",
resumeRunSessionIdBefore: "session-before",
resumeRunSessionIdAfter: "session-after",
taskSession: {
sessionParamsJson: {
sessionId: "other-session",
cwd: "/tmp/project",
},
sessionDisplayId: "other-session",
lastRunId: "run-2",
},
sessionCodec: codexSessionCodec,
});
expect(result).toEqual({
sessionDisplayId: "session-after",
sessionParams: {
sessionId: "session-after",
},
});
});
});
describe("formatRuntimeWorkspaceWarningLog", () => {
it("emits informational workspace warnings on stdout", () => {
expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({

View file

@ -5,7 +5,9 @@ import { errorHandler } from "../middleware/index.js";
import { instanceSettingsRoutes } from "../routes/instance-settings.js";
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(),
getExperimental: vi.fn(),
updateGeneral: vi.fn(),
updateExperimental: vi.fn(),
listCompanyIds: vi.fn(),
}));
@ -31,13 +33,24 @@ function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
});
mockInstanceSettingsService.updateGeneral.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: true,
},
});
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
id: "instance-settings-1",
experimental: {
enableIsolatedWorkspaces: true,
autoRestartDevServerWhenIdle: false,
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
@ -53,7 +66,10 @@ describe("instance settings routes", () => {
const getRes = await request(app).get("/api/instance/settings/experimental");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ enableIsolatedWorkspaces: false });
expect(getRes.body).toEqual({
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
});
const patchRes = await request(app)
.patch("/api/instance/settings/experimental")
@ -66,6 +82,47 @@ describe("instance settings routes", () => {
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("allows local board users to update guarded dev-server auto-restart", async () => {
const app = createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
await request(app)
.patch("/api/instance/settings/experimental")
.send({ autoRestartDevServerWhenIdle: true })
.expect(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
autoRestartDevServerWhenIdle: true,
});
});
it("allows local board users to read and update general settings", async () => {
const app = createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
const getRes = await request(app).get("/api/instance/settings/general");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ censorUsernameInLogs: false });
const patchRes = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true,
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("rejects non-admin board users", async () => {
const app = createApp({
type: "board",
@ -75,10 +132,10 @@ describe("instance settings routes", () => {
companyIds: ["company-1"],
});
const res = await request(app).get("/api/instance/settings/experimental");
const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getExperimental).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
});
it("rejects agent callers", async () => {
@ -90,10 +147,10 @@ describe("instance settings routes", () => {
});
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,146 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
function makeIssue(status: "todo" | "done") {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
status,
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-580",
title: "Comment reopen default",
};
}
describe("issue comment reopen routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "hello",
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: null,
authorUserId: "local-board",
});
mockIssueService.findMentionedAgents.mockResolvedValue([]);
});
it("treats reopen=true as a no-op when the issue is already open", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("todo"),
...patch,
}));
const res = await request(createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.not.objectContaining({ reopened: true }),
}),
);
});
it("reopens closed issues via the PATCH comment path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const res = await request(createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
status: "todo",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
reopened: true,
reopenedFrom: "done",
status: "todo",
}),
}),
);
});
});

View file

@ -0,0 +1,284 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
applyPendingMigrations,
companies,
createDb,
ensurePostgresDatabase,
issueComments,
issues,
} from "@paperclipai/db";
import { issueService } from "../services/issues.ts";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
describe("issueService.list participantAgentId", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
svc = issueService(db);
instance = started.instance;
dataDir = started.dataDir;
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
it("returns issues an agent participated in across the supported signals", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId,
name: "OtherAgent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
const assignedIssueId = randomUUID();
const createdIssueId = randomUUID();
const commentedIssueId = randomUUID();
const activityIssueId = randomUUID();
const excludedIssueId = randomUUID();
await db.insert(issues).values([
{
id: assignedIssueId,
companyId,
title: "Assigned issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
createdByAgentId: otherAgentId,
},
{
id: createdIssueId,
companyId,
title: "Created issue",
status: "todo",
priority: "medium",
createdByAgentId: agentId,
},
{
id: commentedIssueId,
companyId,
title: "Commented issue",
status: "todo",
priority: "medium",
createdByAgentId: otherAgentId,
},
{
id: activityIssueId,
companyId,
title: "Activity issue",
status: "todo",
priority: "medium",
createdByAgentId: otherAgentId,
},
{
id: excludedIssueId,
companyId,
title: "Excluded issue",
status: "todo",
priority: "medium",
createdByAgentId: otherAgentId,
assigneeAgentId: otherAgentId,
},
]);
await db.insert(issueComments).values({
companyId,
issueId: commentedIssueId,
authorAgentId: agentId,
body: "Investigating this issue.",
});
await db.insert(activityLog).values({
companyId,
actorType: "agent",
actorId: agentId,
action: "issue.updated",
entityType: "issue",
entityId: activityIssueId,
agentId,
details: { changed: true },
});
const result = await svc.list(companyId, { participantAgentId: agentId });
const resultIds = new Set(result.map((issue) => issue.id));
expect(resultIds).toEqual(new Set([
assignedIssueId,
createdIssueId,
commentedIssueId,
activityIssueId,
]));
expect(resultIds.has(excludedIssueId)).toBe(false);
});
it("combines participation filtering with search", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const matchedIssueId = randomUUID();
const otherIssueId = randomUUID();
await db.insert(issues).values([
{
id: matchedIssueId,
companyId,
title: "Invoice reconciliation",
status: "todo",
priority: "medium",
createdByAgentId: agentId,
},
{
id: otherIssueId,
companyId,
title: "Weekly planning",
status: "todo",
priority: "medium",
createdByAgentId: agentId,
},
]);
const result = await svc.list(companyId, {
participantAgentId: agentId,
q: "invoice",
});
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
});
});

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
CURRENT_USER_REDACTION_TOKEN,
maskUserNameForLogs,
redactCurrentUserText,
redactCurrentUserValue,
} from "../log-redaction.js";
@ -8,6 +8,7 @@ import {
describe("log redaction", () => {
it("redacts the active username inside home-directory paths", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const input = [
`cwd=/Users/${userName}/paperclip`,
`home=/home/${userName}/workspace`,
@ -19,14 +20,15 @@ describe("log redaction", () => {
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
});
expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`);
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`);
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`);
expect(result).toContain(`cwd=/Users/${maskedUserName}/paperclip`);
expect(result).toContain(`home=/home/${maskedUserName}/workspace`);
expect(result).toContain(`win=C:\\Users\\${maskedUserName}\\paperclip`);
expect(result).not.toContain(userName);
});
it("redacts standalone username mentions without mangling larger tokens", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const result = redactCurrentUserText(
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
{
@ -36,12 +38,13 @@ describe("log redaction", () => {
);
expect(result).toBe(
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`,
`user ${maskedUserName} said ${maskedUserName}/project should stay but apaperclipuserz should not change`,
);
});
it("recursively redacts nested event payloads", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const result = redactCurrentUserValue({
cwd: `/Users/${userName}/paperclip`,
prompt: `open /Users/${userName}/paperclip/ui`,
@ -55,12 +58,17 @@ describe("log redaction", () => {
});
expect(result).toEqual({
cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`,
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`,
cwd: `/Users/${maskedUserName}/paperclip`,
prompt: `open /Users/${maskedUserName}/paperclip/ui`,
nested: {
author: CURRENT_USER_REDACTION_TOKEN,
author: maskedUserName,
},
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`],
values: [maskedUserName, `/home/${maskedUserName}/project`],
});
});
it("skips redaction when disabled", () => {
const input = "cwd=/Users/paperclipuser/paperclip";
expect(redactCurrentUserText(input, { enabled: false })).toBe(input);
});
});

View file

@ -2,15 +2,15 @@ import { describe, expect, it } from "vitest";
import { normalizeAgentMentionToken } from "../services/issues.ts";
describe("normalizeAgentMentionToken", () => {
it("strips hex numeric entities such as space (&#x20;)", () => {
it("decodes hex numeric entities such as space (&#x20;)", () => {
expect(normalizeAgentMentionToken("Baba&#x20;")).toBe("Baba");
});
it("strips decimal numeric entities", () => {
it("decodes decimal numeric entities", () => {
expect(normalizeAgentMentionToken("Baba&#32;")).toBe("Baba");
});
it("strips common named entities", () => {
it("decodes common named whitespace entities", () => {
expect(normalizeAgentMentionToken("Baba&nbsp;")).toBe("Baba");
});
@ -19,11 +19,19 @@ describe("normalizeAgentMentionToken", () => {
expect(normalizeAgentMentionToken("M&amp;M")).toBe("M&M");
});
it("decodes named entities mid-token (e.g. copyright) for full HTML named coverage", () => {
expect(normalizeAgentMentionToken("Agent&copy;Name")).toBe("Agent©Name");
});
it("leaves unknown semicolon-terminated named references unchanged", () => {
expect(normalizeAgentMentionToken("Baba&notarealentity;")).toBe("Baba&notarealentity;");
});
it("returns plain names unchanged", () => {
expect(normalizeAgentMentionToken("Baba")).toBe("Baba");
});
it("trims after stripping entities", () => {
it("trims after decoding entities", () => {
expect(normalizeAgentMentionToken("Baba&#x20;&#x20;")).toBe("Baba");
});
});

View file

@ -23,11 +23,22 @@ const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockBoardAuthService = vi.hoisted(() => ({
createCliAuthChallenge: vi.fn(),
describeCliAuthChallenge: vi.fn(),
approveCliAuthChallenge: vi.fn(),
cancelCliAuthChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),

View file

@ -0,0 +1,90 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listOpenCodeSkills,
syncOpenCodeSkills,
} from "@paperclipai/adapter-opencode-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("opencode local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("reports configured Paperclip skills and installs them into the shared Claude/OpenCode skills home", async () => {
const home = await makeTempDir("paperclip-opencode-skill-sync-");
cleanupDirs.add(home);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "opencode_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listOpenCodeSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncOpenCodeSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-opencode-skill-prune-");
cleanupDirs.add(home);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "opencode_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
await syncOpenCodeSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncOpenCodeSkills(clearedCtx, []);
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View file

@ -30,7 +30,8 @@ describe("paperclip skill utils", () => {
const entries = await listPaperclipSkillEntries(moduleDir);
expect(entries.map((entry) => entry.name)).toEqual(["paperclip"]);
expect(entries.map((entry) => entry.key)).toEqual(["paperclipai/paperclip/paperclip"]);
expect(entries.map((entry) => entry.runtimeName)).toEqual(["paperclip"]);
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
});

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-pi-local/server";
async function writeFakePiCommand(binDir: string, mode: "success" | "stale-package"): Promise<void> {
const commandPath = path.join(binDir, "pi");
const script =
mode === "success"
? `#!/usr/bin/env node
if (process.argv.includes("--list-models")) {
console.log("provider model");
console.log("openai gpt-4.1-mini");
process.exit(0);
}
console.log(JSON.stringify({ type: "session", version: 3, id: "session-1", timestamp: new Date().toISOString(), cwd: process.cwd() }));
console.log(JSON.stringify({ type: "agent_start" }));
console.log(JSON.stringify({ type: "turn_start" }));
console.log(JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: [{ type: "text", text: "hello" }],
usage: { input: 1, output: 1, cacheRead: 0, cost: { total: 0 } }
},
toolResults: []
}));
`
: `#!/usr/bin/env node
if (process.argv.includes("--list-models")) {
console.error("npm error 404 'pi-driver@*' is not in this registry.");
process.exit(1);
}
process.exit(1);
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
describe("pi_local environment diagnostics", () => {
it("passes a hello probe when model discovery and execution succeed", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-pi-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const binDir = path.join(root, "bin");
const cwd = path.join(root, "workspace");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(cwd, { recursive: true });
await writeFakePiCommand(binDir, "success");
const result = await testEnvironment({
companyId: "company-1",
adapterType: "pi_local",
config: {
command: "pi",
cwd,
model: "openai/gpt-4.1-mini",
env: {
OPENAI_API_KEY: "test-key",
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
});
expect(result.status).toBe("pass");
expect(result.checks.some((check) => check.code === "pi_models_discovered")).toBe(true);
expect(result.checks.some((check) => check.code === "pi_hello_probe_passed")).toBe(true);
await fs.rm(root, { recursive: true, force: true });
});
it("surfaces stale configured package installs with a targeted hint", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-pi-local-stale-package-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const binDir = path.join(root, "bin");
const cwd = path.join(root, "workspace");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(cwd, { recursive: true });
await writeFakePiCommand(binDir, "stale-package");
const result = await testEnvironment({
companyId: "company-1",
adapterType: "pi_local",
config: {
command: "pi",
cwd,
env: {
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
});
const stalePackageCheck = result.checks.find((check) => check.code === "pi_package_install_failed");
expect(stalePackageCheck?.level).toBe("warn");
expect(stalePackageCheck?.hint).toContain("Remove `npm:pi-driver`");
await fs.rm(root, { recursive: true, force: true });
});
});

View file

@ -0,0 +1,89 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listPiSkills,
syncPiSkills,
} from "@paperclipai/adapter-pi-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("pi local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("reports configured Paperclip skills and installs them into the Pi skills home", async () => {
const home = await makeTempDir("paperclip-pi-skill-sync-");
cleanupDirs.add(home);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "pi_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listPiSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncPiSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-pi-skill-prune-");
cleanupDirs.add(home);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "pi_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [paperclipKey],
},
},
} as const;
await syncPiSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncPiSkills(clearedCtx, []);
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View file

@ -0,0 +1,340 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentWakeupRequests,
agents,
applyPendingMigrations,
companies,
companyMemberships,
createDb,
ensurePostgresDatabase,
heartbeatRunEvents,
heartbeatRuns,
instanceSettings,
issues,
principalPermissionGrants,
projects,
routineRuns,
routines,
routineTriggers,
} from "@paperclipai/db";
import { errorHandler } from "../middleware/index.js";
import { accessService } from "../services/access.js";
vi.mock("../services/index.js", async () => {
const actual = await vi.importActual<typeof import("../services/index.js")>("../services/index.js");
const { randomUUID } = await import("node:crypto");
const { eq } = await import("drizzle-orm");
const { heartbeatRuns, issues } = await import("@paperclipai/db");
return {
...actual,
routineService: (db: any) =>
actual.routineService(db, {
heartbeat: {
wakeup: async (agentId: string, wakeupOpts: any) => {
const issueId =
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
if (!issueId) return null;
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
if (!issue) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId: issue.companyId,
agentId,
invocationSource: wakeupOpts?.source ?? "assignment",
triggerDetail: wakeupOpts?.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
},
},
}),
};
});
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
describe("routine routes end-to-end", () => {
let db!: ReturnType<typeof createDb>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
instance = started.instance;
dataDir = started.dataDir;
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issues);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(routines);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
await db.delete(instanceSettings);
});
afterAll(async () => {
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
async function createApp(actor: Record<string, unknown>) {
const { routineRoutes } = await import("../routes/routines.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", routineRoutes(db));
app.use(errorHandler);
return app;
}
async function seedFixture() {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const userId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Routine Project",
status: "in_progress",
});
const access = accessService(db);
const membership = await access.ensureMembership(companyId, "user", userId, "owner", "active");
await access.setMemberPermissions(
companyId,
membership.id,
[{ permissionKey: "tasks:assign" }],
userId,
);
return { companyId, agentId, projectId, userId };
}
it("supports creating, scheduling, and manually running a routine through the API", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const app = await createApp({
type: "board",
userId,
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const createRes = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Daily standup prep",
description: "Summarize blockers and open PRs",
assigneeAgentId: agentId,
priority: "high",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
});
expect(createRes.status).toBe(201);
expect(createRes.body.title).toBe("Daily standup prep");
expect(createRes.body.assigneeAgentId).toBe(agentId);
const routineId = createRes.body.id as string;
const triggerRes = await request(app)
.post(`/api/routines/${routineId}/triggers`)
.send({
kind: "schedule",
label: "Weekday morning",
cronExpression: "0 10 * * 1-5",
timezone: "UTC",
});
expect(triggerRes.status).toBe(201);
expect(triggerRes.body.trigger.kind).toBe("schedule");
expect(triggerRes.body.trigger.enabled).toBe(true);
expect(triggerRes.body.secretMaterial).toBeNull();
const runRes = await request(app)
.post(`/api/routines/${routineId}/run`)
.send({
source: "manual",
payload: { origin: "e2e-test" },
});
expect(runRes.status).toBe(202);
expect(runRes.body.status).toBe("issue_created");
expect(runRes.body.source).toBe("manual");
expect(runRes.body.linkedIssueId).toBeTruthy();
const detailRes = await request(app).get(`/api/routines/${routineId}`);
expect(detailRes.status).toBe(200);
expect(detailRes.body.triggers).toHaveLength(1);
expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id);
expect(detailRes.body.recentRuns).toHaveLength(1);
expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id);
expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId);
const runsRes = await request(app).get(`/api/routines/${routineId}/runs?limit=10`);
expect(runsRes.status).toBe(200);
expect(runsRes.body).toHaveLength(1);
expect(runsRes.body[0]?.id).toBe(runRes.body.id);
const [issue] = await db
.select({
id: issues.id,
originId: issues.originId,
originKind: issues.originKind,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, runRes.body.linkedIssueId));
expect(issue).toMatchObject({
id: runRes.body.linkedIssueId,
originId: routineId,
originKind: "routine_execution",
});
expect(issue?.executionRunId).toBeTruthy();
const actions = await db
.select({
action: activityLog.action,
})
.from(activityLog)
.where(eq(activityLog.companyId, companyId));
expect(actions.map((entry) => entry.action)).toEqual(
expect.arrayContaining([
"routine.created",
"routine.trigger_created",
"routine.run_triggered",
]),
);
});
});

View file

@ -0,0 +1,271 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { routineRoutes } from "../routes/routines.js";
import { errorHandler } from "../middleware/index.js";
const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "11111111-1111-4111-8111-111111111111";
const routineId = "33333333-3333-4333-8333-333333333333";
const projectId = "44444444-4444-4444-8444-444444444444";
const otherAgentId = "55555555-5555-4555-8555-555555555555";
const routine = {
id: routineId,
companyId,
projectId,
goalId: null,
parentIssueId: null,
title: "Daily routine",
description: null,
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
};
const pausedRoutine = {
...routine,
status: "paused",
};
const trigger = {
id: "66666666-6666-4666-8666-666666666666",
companyId,
routineId,
kind: "schedule",
label: "weekday",
enabled: false,
cronExpression: "0 10 * * 1-5",
timezone: "UTC",
nextRunAt: null,
lastFiredAt: null,
publicId: null,
secretId: null,
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
};
const mockRoutineService = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
getDetail: vi.fn(),
update: vi.fn(),
create: vi.fn(),
listRuns: vi.fn(),
createTrigger: vi.fn(),
getTrigger: vi.fn(),
updateTrigger: vi.fn(),
deleteTrigger: vi.fn(),
rotateTriggerSecret: vi.fn(),
runRoutine: vi.fn(),
firePublicTrigger: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", routineRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("routine routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRoutineService.create.mockResolvedValue(routine);
mockRoutineService.get.mockResolvedValue(routine);
mockRoutineService.getTrigger.mockResolvedValue(trigger);
mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId });
mockRoutineService.runRoutine.mockResolvedValue({
id: "run-1",
source: "manual",
status: "issue_created",
});
mockAccessService.canUser.mockResolvedValue(false);
mockLogActivity.mockResolvedValue(undefined);
});
it("requires tasks:assign permission for non-admin board routine creation", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.create).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to retarget a routine assignee", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routines/${routineId}`)
.send({
assigneeAgentId: otherAgentId,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.update).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to reactivate a routine", async () => {
mockRoutineService.get.mockResolvedValue(pausedRoutine);
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routines/${routineId}`)
.send({
status: "active",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.update).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to create a trigger", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/routines/${routineId}/triggers`)
.send({
kind: "schedule",
cronExpression: "0 10 * * *",
timezone: "UTC",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.createTrigger).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to update a trigger", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routine-triggers/${trigger.id}`)
.send({
enabled: true,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.updateTrigger).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to manually run a routine", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/routines/${routineId}/run`)
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.runRoutine).not.toHaveBeenCalled();
});
it("allows routine creation when the board user has tasks:assign", async () => {
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
});
expect(res.status).toBe(201);
expect(mockRoutineService.create).toHaveBeenCalledWith(companyId, expect.objectContaining({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
}), {
agentId: null,
userId: "board-user",
});
});
});

View file

@ -0,0 +1,488 @@
import { createHmac, randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
applyPendingMigrations,
companies,
companySecrets,
companySecretVersions,
createDb,
ensurePostgresDatabase,
heartbeatRuns,
issues,
projects,
routineRuns,
routines,
routineTriggers,
} from "@paperclipai/db";
import { issueService } from "../services/issues.ts";
import { routineService } from "../services/routines.ts";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
describe("routine service live-execution coalescing", () => {
let db!: ReturnType<typeof createDb>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
instance = started.instance;
dataDir = started.dataDir;
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routines);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
async function seedFixture(opts?: {
wakeup?: (
agentId: string,
wakeupOpts: {
source?: string;
triggerDetail?: string;
reason?: string | null;
payload?: Record<string, unknown> | null;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
},
) => Promise<unknown>;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const wakeups: Array<{
agentId: string;
opts: {
source?: string;
triggerDetail?: string;
reason?: string | null;
payload?: Record<string, unknown> | null;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
};
}> = [];
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Routines",
status: "in_progress",
});
const svc = routineService(db, {
heartbeat: {
wakeup: async (wakeupAgentId, wakeupOpts) => {
wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts });
if (opts?.wakeup) return opts.wakeup(wakeupAgentId, wakeupOpts);
const issueId =
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
if (!issueId) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId,
agentId: wakeupAgentId,
invocationSource: wakeupOpts.source ?? "assignment",
triggerDetail: wakeupOpts.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
},
},
});
const issueSvc = issueService(db);
const routine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "ascii frog",
description: "Run the frog routine",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
},
{},
);
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
}
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
const { companyId, issueSvc, routine, svc } = await seedFixture();
const previousRunId = randomUUID();
const previousIssue = await issueSvc.create(companyId, {
projectId: routine.projectId,
title: routine.title,
description: routine.description,
status: "todo",
priority: routine.priority,
assigneeAgentId: routine.assigneeAgentId,
originKind: "routine_execution",
originId: routine.id,
originRunId: previousRunId,
});
await db.insert(routineRuns).values({
id: previousRunId,
companyId,
routineId: routine.id,
triggerId: null,
source: "manual",
status: "issue_created",
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
linkedIssueId: previousIssue.id,
completedAt: new Date("2026-03-20T12:00:00.000Z"),
});
const detailBefore = await svc.getDetail(routine.id);
expect(detailBefore?.activeIssue).toBeNull();
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("issue_created");
expect(run.linkedIssueId).not.toBe(previousIssue.id);
const routineIssues = await db
.select({
id: issues.id,
originRunId: issues.originRunId,
})
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(2);
expect(routineIssues.map((issue) => issue.id)).toContain(previousIssue.id);
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
});
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
const { agentId, routine, svc, wakeups } = await seedFixture();
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("issue_created");
expect(run.linkedIssueId).toBeTruthy();
expect(wakeups).toEqual([
{
agentId,
opts: {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId: run.linkedIssueId, mutation: "create" },
requestedByActorType: undefined,
requestedByActorId: null,
contextSnapshot: { issueId: run.linkedIssueId, source: "routine.dispatch" },
},
},
]);
});
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
let wakeupResolved = false;
const { routine, svc } = await seedFixture({
wakeup: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
wakeupResolved = true;
return null;
},
});
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("issue_created");
expect(wakeupResolved).toBe(true);
});
it("coalesces only when the existing routine issue has a live execution run", async () => {
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
const previousRunId = randomUUID();
const liveHeartbeatRunId = randomUUID();
const previousIssue = await issueSvc.create(companyId, {
projectId: routine.projectId,
title: routine.title,
description: routine.description,
status: "in_progress",
priority: routine.priority,
assigneeAgentId: routine.assigneeAgentId,
originKind: "routine_execution",
originId: routine.id,
originRunId: previousRunId,
});
await db.insert(routineRuns).values({
id: previousRunId,
companyId,
routineId: routine.id,
triggerId: null,
source: "manual",
status: "issue_created",
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
linkedIssueId: previousIssue.id,
});
await db.insert(heartbeatRuns).values({
id: liveHeartbeatRunId,
companyId,
agentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "running",
contextSnapshot: { issueId: previousIssue.id },
startedAt: new Date("2026-03-20T12:01:00.000Z"),
});
await db
.update(issues)
.set({
checkoutRunId: liveHeartbeatRunId,
executionRunId: liveHeartbeatRunId,
executionLockedAt: new Date("2026-03-20T12:01:00.000Z"),
})
.where(eq(issues.id, previousIssue.id));
const detailBefore = await svc.getDetail(routine.id);
expect(detailBefore?.activeIssue?.id).toBe(previousIssue.id);
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("coalesced");
expect(run.linkedIssueId).toBe(previousIssue.id);
expect(run.coalescedIntoRunId).toBe(previousRunId);
const routineIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(1);
expect(routineIssues[0]?.id).toBe(previousIssue.id);
});
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
const { routine, svc } = await seedFixture({
wakeup: async (wakeupAgentId, wakeupOpts) => {
const issueId =
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
await new Promise((resolve) => setTimeout(resolve, 25));
if (!issueId) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId: routine.companyId,
agentId: wakeupAgentId,
invocationSource: wakeupOpts.source ?? "assignment",
triggerDetail: wakeupOpts.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
},
});
const [first, second] = await Promise.all([
svc.runRoutine(routine.id, { source: "manual" }),
svc.runRoutine(routine.id, { source: "manual" }),
]);
expect([first.status, second.status].sort()).toEqual(["coalesced", "issue_created"]);
expect(first.linkedIssueId).toBeTruthy();
expect(second.linkedIssueId).toBeTruthy();
expect(first.linkedIssueId).toBe(second.linkedIssueId);
const routineIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(1);
});
it("fails the run and cleans up the execution issue when wakeup queueing fails", async () => {
const { routine, svc } = await seedFixture({
wakeup: async () => {
throw new Error("queue unavailable");
},
});
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("failed");
expect(run.failureReason).toContain("queue unavailable");
expect(run.linkedIssueId).toBeNull();
const routineIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(0);
});
it("accepts standard second-precision webhook timestamps for HMAC triggers", async () => {
const { routine, svc } = await seedFixture();
const { trigger, secretMaterial } = await svc.createTrigger(
routine.id,
{
kind: "webhook",
signingMode: "hmac_sha256",
replayWindowSec: 300,
},
{},
);
expect(trigger.publicId).toBeTruthy();
expect(secretMaterial?.webhookSecret).toBeTruthy();
const payload = { ok: true };
const rawBody = Buffer.from(JSON.stringify(payload));
const timestampSeconds = String(Math.floor(Date.now() / 1000));
const signature = `sha256=${createHmac("sha256", secretMaterial!.webhookSecret)
.update(`${timestampSeconds}.`)
.update(rawBody)
.digest("hex")}`;
const run = await svc.firePublicTrigger(trigger.publicId!, {
signatureHeader: signature,
timestampHeader: timestampSeconds,
rawBody,
payload,
});
expect(run.source).toBe("webhook");
expect(run.status).toBe("issue_created");
expect(run.linkedIssueId).toBeTruthy();
});
});

View file

@ -2,6 +2,8 @@ import type { ServerAdapterModule } from "./types.js";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import {
execute as claudeExecute,
listClaudeSkills,
syncClaudeSkills,
testEnvironment as claudeTestEnvironment,
sessionCodec as claudeSessionCodec,
getQuotaWindows as claudeGetQuotaWindows,
@ -9,6 +11,8 @@ import {
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local";
import {
execute as codexExecute,
listCodexSkills,
syncCodexSkills,
testEnvironment as codexTestEnvironment,
sessionCodec as codexSessionCodec,
getQuotaWindows as codexGetQuotaWindows,
@ -16,18 +20,24 @@ import {
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local";
import {
execute as cursorExecute,
listCursorSkills,
syncCursorSkills,
testEnvironment as cursorTestEnvironment,
sessionCodec as cursorSessionCodec,
} from "@paperclipai/adapter-cursor-local/server";
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
import {
execute as geminiExecute,
listGeminiSkills,
syncGeminiSkills,
testEnvironment as geminiTestEnvironment,
sessionCodec as geminiSessionCodec,
} from "@paperclipai/adapter-gemini-local/server";
import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local";
import {
execute as openCodeExecute,
listOpenCodeSkills,
syncOpenCodeSkills,
testEnvironment as openCodeTestEnvironment,
sessionCodec as openCodeSessionCodec,
listOpenCodeModels,
@ -47,6 +57,8 @@ import { listCodexModels } from "./codex-models.js";
import { listCursorModels } from "./cursor-models.js";
import {
execute as piExecute,
listPiSkills,
syncPiSkills,
testEnvironment as piTestEnvironment,
sessionCodec as piSessionCodec,
listPiModels,
@ -70,6 +82,8 @@ const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
listSkills: listClaudeSkills,
syncSkills: syncClaudeSkills,
sessionCodec: claudeSessionCodec,
sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined,
models: claudeModels,
@ -82,6 +96,8 @@ const codexLocalAdapter: ServerAdapterModule = {
type: "codex_local",
execute: codexExecute,
testEnvironment: codexTestEnvironment,
listSkills: listCodexSkills,
syncSkills: syncCodexSkills,
sessionCodec: codexSessionCodec,
sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined,
models: codexModels,
@ -95,6 +111,8 @@ const cursorLocalAdapter: ServerAdapterModule = {
type: "cursor",
execute: cursorExecute,
testEnvironment: cursorTestEnvironment,
listSkills: listCursorSkills,
syncSkills: syncCursorSkills,
sessionCodec: cursorSessionCodec,
sessionManagement: getAdapterSessionManagement("cursor") ?? undefined,
models: cursorModels,
@ -107,6 +125,8 @@ const geminiLocalAdapter: ServerAdapterModule = {
type: "gemini_local",
execute: geminiExecute,
testEnvironment: geminiTestEnvironment,
listSkills: listGeminiSkills,
syncSkills: syncGeminiSkills,
sessionCodec: geminiSessionCodec,
sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined,
models: geminiModels,
@ -127,6 +147,8 @@ const openCodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment,
listSkills: listOpenCodeSkills,
syncSkills: syncOpenCodeSkills,
sessionCodec: openCodeSessionCodec,
sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined,
models: [],
@ -139,6 +161,8 @@ const piLocalAdapter: ServerAdapterModule = {
type: "pi_local",
execute: piExecute,
testEnvironment: piTestEnvironment,
listSkills: listPiSkills,
syncSkills: syncPiSkills,
sessionCodec: piSessionCodec,
sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined,
models: [],

View file

@ -14,6 +14,12 @@ export type {
AdapterEnvironmentTestStatus,
AdapterEnvironmentTestResult,
AdapterEnvironmentTestContext,
AdapterSkillSyncMode,
AdapterSkillState,
AdapterSkillOrigin,
AdapterSkillEntry,
AdapterSkillSnapshot,
AdapterSkillContext,
AdapterSessionCodec,
AdapterModel,
NativeContextManagement,

View file

@ -11,9 +11,11 @@ import { boardMutationGuard } from "./middleware/board-mutation-guard.js";
import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js";
import { healthRoutes } from "./routes/health.js";
import { companyRoutes } from "./routes/companies.js";
import { companySkillRoutes } from "./routes/company-skills.js";
import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js";
import { issueRoutes } from "./routes/issues.js";
import { routineRoutes } from "./routes/routines.js";
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
import { goalRoutes } from "./routes/goals.js";
import { approvalRoutes } from "./routes/approvals.js";
@ -77,6 +79,8 @@ export async function createApp(
const app = express();
app.use(express.json({
// Company import/export payloads can inline full portable packages.
limit: "10mb",
verify: (req, _res, buf) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
},
@ -135,11 +139,13 @@ export async function createApp(
companyDeletionEnabled: opts.companyDeletionEnabled,
}),
);
api.use("/companies", companyRoutes(db));
api.use("/companies", companyRoutes(db, opts.storageService));
api.use(companySkillRoutes(db));
api.use(agentRoutes(db));
api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db));
api.use(issueRoutes(db, opts.storageService));
api.use(routineRoutes(db));
api.use(executionWorkspaceRoutes(db));
api.use(goalRoutes(db));
api.use(approvalRoutes(db));

View file

@ -0,0 +1,103 @@
import { existsSync, readFileSync } from "node:fs";
export type PersistedDevServerStatus = {
dirty: boolean;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
lastRestartAt: string | null;
};
export type DevServerHealthStatus = {
enabled: true;
restartRequired: boolean;
reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
autoRestartEnabled: boolean;
activeRunCount: number;
waitingForIdle: boolean;
lastRestartAt: string | null;
};
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function normalizeTimestamp(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function readPersistedDevServerStatus(
env: NodeJS.ProcessEnv = process.env,
): PersistedDevServerStatus | null {
const filePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim();
if (!filePath || !existsSync(filePath)) return null;
try {
const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
const changedPathCountRaw = raw.changedPathCount;
const changedPathCount =
typeof changedPathCountRaw === "number" && Number.isFinite(changedPathCountRaw)
? Math.max(0, Math.trunc(changedPathCountRaw))
: changedPathsSample.length;
const dirtyRaw = raw.dirty;
const dirty =
typeof dirtyRaw === "boolean"
? dirtyRaw
: changedPathCount > 0 || pendingMigrations.length > 0;
return {
dirty,
lastChangedAt: normalizeTimestamp(raw.lastChangedAt),
changedPathCount,
changedPathsSample,
pendingMigrations,
lastRestartAt: normalizeTimestamp(raw.lastRestartAt),
};
} catch {
return null;
}
}
export function toDevServerHealthStatus(
persisted: PersistedDevServerStatus,
opts: { autoRestartEnabled: boolean; activeRunCount: number },
): DevServerHealthStatus {
const hasPathChanges = persisted.changedPathCount > 0;
const hasPendingMigrations = persisted.pendingMigrations.length > 0;
const reason =
hasPathChanges && hasPendingMigrations
? "backend_changes_and_pending_migrations"
: hasPendingMigrations
? "pending_migrations"
: hasPathChanges
? "backend_changes"
: null;
const restartRequired = persisted.dirty || reason !== null;
return {
enabled: true,
restartRequired,
reason,
lastChangedAt: persisted.lastChangedAt,
changedPathCount: persisted.changedPathCount,
changedPathsSample: persisted.changedPathsSample,
pendingMigrations: persisted.pendingMigrations,
autoRestartEnabled: opts.autoRestartEnabled,
activeRunCount: opts.activeRunCount,
waitingForIdle: restartRequired && opts.autoRestartEnabled && opts.activeRunCount > 0,
lastRestartAt: persisted.lastRestartAt,
};
}

View file

@ -26,7 +26,7 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@ -526,6 +526,7 @@ export async function startServer(): Promise<StartedServer> {
if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any);
const routines = routineService(db as any);
// Reap orphaned running runs at startup while in-memory execution state is empty,
// then resume any persisted queued runs that were waiting on the previous process.
@ -546,6 +547,17 @@ export async function startServer(): Promise<StartedServer> {
.catch((err) => {
logger.error({ err }, "heartbeat timer tick failed");
});
void routines
.tickScheduledTriggers(new Date())
.then((result) => {
if (result.triggered > 0) {
logger.info({ ...result }, "routine scheduler tick enqueued runs");
}
})
.catch((err) => {
logger.error({ err }, "routine scheduler tick failed");
});
// Periodically reap orphaned runs (5-min staleness threshold) and make sure
// persisted queued work is still being driven forward.

View file

@ -1,8 +1,9 @@
import os from "node:os";
export const CURRENT_USER_REDACTION_TOKEN = "[]";
export const CURRENT_USER_REDACTION_TOKEN = "*";
interface CurrentUserRedactionOptions {
export interface CurrentUserRedactionOptions {
enabled?: boolean;
replacement?: string;
userNames?: string[];
homeDirs?: string[];
@ -39,6 +40,12 @@ function replaceLastPathSegment(pathValue: string, replacement: string) {
return `${normalized.slice(0, lastSeparator + 1)}${replacement}`;
}
export function maskUserNameForLogs(value: string, fallback = CURRENT_USER_REDACTION_TOKEN) {
const trimmed = value.trim();
if (!trimmed) return fallback;
return `${trimmed[0]}${"*".repeat(Math.max(1, Array.from(trimmed).length - 1))}`;
}
function defaultUserNames() {
const candidates = [
process.env.USER,
@ -99,21 +106,22 @@ function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) {
export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) {
if (!input) return input;
if (opts?.enabled === false) return input;
const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts);
let result = input;
for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) {
const lastSegment = splitPathSegments(homeDir).pop() ?? "";
const replacementDir = userNames.includes(lastSegment)
? replaceLastPathSegment(homeDir, replacement)
const replacementDir = lastSegment
? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement))
: replacement;
result = result.split(homeDir).join(replacementDir);
}
for (const userName of [...userNames].sort((a, b) => b.length - a.length)) {
const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
result = result.replace(pattern, replacement);
result = result.replace(pattern, maskUserNameForLogs(userName, replacement));
}
return result;

View file

@ -7,6 +7,7 @@ import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
import type { DeploymentMode } from "@paperclipai/shared";
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
import { logger } from "./logger.js";
import { boardAuthService } from "../services/board-auth.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
@ -18,6 +19,7 @@ interface ActorMiddlewareOptions {
}
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
const boardAuth = boardAuthService(db);
return async (req, _res, next) => {
req.actor =
opts.deploymentMode === "local_trusted"
@ -80,6 +82,25 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return;
}
const boardKey = await boardAuth.findBoardApiKeyByToken(token);
if (boardKey) {
const access = await boardAuth.resolveBoardAccess(boardKey.userId);
if (access.user) {
await boardAuth.touchBoardApiKey(boardKey.id);
req.actor = {
type: "board",
userId: boardKey.userId,
companyIds: access.companyIds,
isInstanceAdmin: access.isInstanceAdmin,
keyId: boardKey.id,
runId: runIdHeader || undefined,
source: "board_key",
};
next();
return;
}
}
const tokenHash = hashToken(token);
const key = await db
.select()

View file

@ -49,10 +49,9 @@ export function boardMutationGuard(): RequestHandler {
return;
}
// Local-trusted mode uses an implicit board actor for localhost-only development.
// In this mode, origin/referer headers can be omitted by some clients for multipart
// uploads; do not block those mutations.
if (req.actor.source === "local_implicit") {
// Local-trusted mode and board bearer keys are not browser-session requests.
// In these modes, origin/referer headers can be absent; do not block those mutations.
if (req.actor.source === "local_implicit" || req.actor.source === "board_key") {
next();
return;
}

View file

@ -0,0 +1,24 @@
You are the CEO.
Your home directory is $AGENT_HOME. Everything personal to you -- life, memory, knowledge -- lives there. Other agents may have their own folders and you may update them when necessary.
Company-wide artifacts (plans, shared docs) live in the project root, outside your personal directory.
## Memory and Planning
You MUST use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans. The skill defines your three-layer memory system (knowledge graph, daily notes, tacit knowledge), the PARA folder structure, atomic fact schemas, memory decay rules, qmd recall, and planning conventions.
Invoke it whenever you need to remember, retrieve, or organize anything.
## Safety Considerations
- Never exfiltrate secrets or private data.
- Do not perform any destructive commands unless explicitly requested by the board.
## References
These files are essential. Read them.
- `$AGENT_HOME/HEARTBEAT.md` -- execution and extraction checklist. Run every heartbeat.
- `$AGENT_HOME/SOUL.md` -- who you are and how you should act.
- `$AGENT_HOME/TOOLS.md` -- tools you have access to

View file

@ -0,0 +1,72 @@
# HEARTBEAT.md -- CEO Heartbeat Checklist
Run this checklist on every heartbeat. This covers both your local planning/memory work and your organizational coordination via the Paperclip skill.
## 1. Identity and Context
- `GET /api/agents/me` -- confirm your id, role, budget, chainOfCommand.
- Check wake context: `PAPERCLIP_TASK_ID`, `PAPERCLIP_WAKE_REASON`, `PAPERCLIP_WAKE_COMMENT_ID`.
## 2. Local Planning Check
1. Read today's plan from `$AGENT_HOME/memory/YYYY-MM-DD.md` under "## Today's Plan".
2. Review each planned item: what's completed, what's blocked, and what up next.
3. For any blockers, resolve them yourself or escalate to the board.
4. If you're ahead, start on the next highest priority.
5. Record progress updates in the daily notes.
## 3. Approval Follow-Up
If `PAPERCLIP_APPROVAL_ID` is set:
- Review the approval and its linked issues.
- Close resolved issues or comment on what remains open.
## 4. Get Assignments
- `GET /api/companies/{companyId}/issues?assigneeAgentId={your-id}&status=todo,in_progress,blocked`
- Prioritize: `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it.
- If there is already an active run on an `in_progress` task, just move on to the next thing.
- If `PAPERCLIP_TASK_ID` is set and assigned to you, prioritize that task.
## 5. Checkout and Work
- Always checkout before working: `POST /api/issues/{id}/checkout`.
- Never retry a 409 -- that task belongs to someone else.
- Do the work. Update status and comment when done.
## 6. Delegation
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`.
- Use `paperclip-create-agent` skill when hiring new agents.
- Assign work to the right agent for the job.
## 7. Fact Extraction
1. Check for new conversations since last extraction.
2. Extract durable facts to the relevant entity in `$AGENT_HOME/life/` (PARA).
3. Update `$AGENT_HOME/memory/YYYY-MM-DD.md` with timeline entries.
4. Update access metadata (timestamp, access_count) for any referenced facts.
## 8. Exit
- Comment on any in_progress work before exiting.
- If no assignments and no valid mention-handoff, exit cleanly.
---
## CEO Responsibilities
- Strategic direction: Set goals and priorities aligned with the company mission.
- Hiring: Spin up new agents when capacity is needed.
- Unblocking: Escalate or resolve blockers for reports.
- Budget awareness: Above 80% spend, focus only on critical tasks.
- Never look for unassigned work -- only work on what is assigned to you.
- Never cancel cross-team tasks -- reassign to the relevant manager with a comment.
## Rules
- Always use the Paperclip skill for coordination.
- Always include `X-Paperclip-Run-Id` header on mutating API calls.
- Comment in concise markdown: status line + bullets + links.
- Self-assign via checkout only when explicitly @-mentioned.

View file

@ -0,0 +1,33 @@
# SOUL.md -- CEO Persona
You are the CEO.
## Strategic Posture
- You own the P&L. Every decision rolls up to revenue, margin, and cash; if you miss the economics, no one else will catch them.
- Default to action. Ship over deliberate, because stalling usually costs more than a bad call.
- Hold the long view while executing the near term. Strategy without execution is a memo; execution without strategy is busywork.
- Protect focus hard. Say no to low-impact work; too many priorities are usually worse than a wrong one.
- In trade-offs, optimize for learning speed and reversibility. Move fast on two-way doors; slow down on one-way doors.
- Know the numbers cold. Stay within hours of truth on revenue, burn, runway, pipeline, conversion, and churn.
- Treat every dollar, headcount, and engineering hour as a bet. Know the thesis and expected return.
- Think in constraints, not wishes. Ask "what do we stop?" before "what do we add?"
- Hire slow, fire fast, and avoid leadership vacuums. The team is the strategy.
- Create organizational clarity. If priorities are unclear, it's on you; repeat strategy until it sticks.
- Pull for bad news and reward candor. If problems stop surfacing, you've lost your information edge.
- Stay close to the customer. Dashboards help, but regular firsthand conversations keep you honest.
- Be replaceable in operations and irreplaceable in judgment. Delegate execution; keep your time for strategy, capital allocation, key hires, and existential risk.
## Voice and Tone
- Be direct. Lead with the point, then give context. Never bury the ask.
- Write like you talk in a board meeting, not a blog post. Short sentences, active voice, no filler.
- Confident but not performative. You don't need to sound smart; you need to be clear.
- Match intensity to stakes. A product launch gets energy. A staffing call gets gravity. A Slack reply gets brevity.
- Skip the corporate warm-up. No "I hope this message finds you well." Get to it.
- Use plain language. If a simpler word works, use it. "Use" not "utilize." "Start" not "initiate."
- Own uncertainty when it exists. "I don't know yet" beats a hedged non-answer every time.
- Disagree openly, but without heat. Challenge ideas, not people.
- Keep praise specific and rare enough to mean something. "Good job" is noise. "The way you reframed the pricing model saved us a quarter" is signal.
- Default to async-friendly writing. Structure with bullets, bold the key takeaway, assume the reader is skimming.
- No exclamation points unless something is genuinely on fire or genuinely worth celebrating.

View file

@ -0,0 +1,3 @@
# Tools
(Your tools will go here. Add notes about them as you acquire and use them.)

View file

@ -0,0 +1,3 @@
You are an agent at Paperclip company.
Keep the work moving until it's done. If you need QA to review it, ask them. If you need your boss to review it, ask them. If someone needs to unblock you, assign them the ticket with a comment asking for what you need. Don't let work just sit here. You must always update your task with a comment.

View file

@ -19,10 +19,12 @@ import {
} from "@paperclipai/db";
import {
acceptInviteSchema,
createCliAuthChallengeSchema,
claimJoinRequestApiKeySchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
listJoinRequestsQuerySchema,
resolveCliAuthChallengeSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
PERMISSION_KEYS
@ -40,6 +42,7 @@ import { validate } from "../middleware/validate.js";
import {
accessService,
agentService,
boardAuthService,
deduplicateAgentName,
logActivity,
notifyHireApproved
@ -95,6 +98,10 @@ function requestBaseUrl(req: Request) {
return `${proto}://${host}`;
}
function buildCliAuthApprovalPath(challengeId: string, token: string) {
return `/cli-auth/${challengeId}?token=${encodeURIComponent(token)}`;
}
function readSkillMarkdown(skillName: string): string | null {
const normalized = skillName.trim().toLowerCase();
if (
@ -1537,6 +1544,7 @@ export function accessRoutes(
) {
const router = Router();
const access = accessService(db);
const boardAuth = boardAuthService(db);
const agents = agentService(db);
async function assertInstanceAdmin(req: Request) {
@ -1594,6 +1602,166 @@ export function accessRoutes(
throw conflict("Board claim challenge is no longer available");
});
router.post(
"/cli-auth/challenges",
validate(createCliAuthChallengeSchema),
async (req, res) => {
const created = await boardAuth.createCliAuthChallenge(req.body);
const approvalPath = buildCliAuthApprovalPath(
created.challenge.id,
created.challengeSecret,
);
const baseUrl = requestBaseUrl(req);
res.status(201).json({
id: created.challenge.id,
token: created.challengeSecret,
boardApiToken: created.pendingBoardToken,
approvalPath,
approvalUrl: baseUrl ? `${baseUrl}${approvalPath}` : null,
pollPath: `/cli-auth/challenges/${created.challenge.id}`,
expiresAt: created.challenge.expiresAt.toISOString(),
suggestedPollIntervalMs: 1000,
});
},
);
router.get("/cli-auth/challenges/:id", async (req, res) => {
const id = (req.params.id as string).trim();
const token =
typeof req.query.token === "string" ? req.query.token.trim() : "";
if (!id || !token) throw notFound("CLI auth challenge not found");
const challenge = await boardAuth.describeCliAuthChallenge(id, token);
if (!challenge) throw notFound("CLI auth challenge not found");
const isSignedInBoardUser =
req.actor.type === "board" &&
(req.actor.source === "session" || isLocalImplicit(req)) &&
Boolean(req.actor.userId);
const canApprove =
isSignedInBoardUser &&
(challenge.requestedAccess !== "instance_admin_required" ||
isLocalImplicit(req) ||
Boolean(req.actor.isInstanceAdmin));
res.json({
...challenge,
requiresSignIn: !isSignedInBoardUser,
canApprove,
currentUserId: req.actor.type === "board" ? req.actor.userId ?? null : null,
});
});
router.post(
"/cli-auth/challenges/:id/approve",
validate(resolveCliAuthChallengeSchema),
async (req, res) => {
const id = (req.params.id as string).trim();
if (
req.actor.type !== "board" ||
(!req.actor.userId && !isLocalImplicit(req))
) {
throw unauthorized("Sign in before approving CLI access");
}
const userId = req.actor.userId ?? "local-board";
const approved = await boardAuth.approveCliAuthChallenge(
id,
req.body.token,
userId,
);
if (approved.status === "approved") {
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
userId,
requestedCompanyId: approved.challenge.requestedCompanyId,
boardApiKeyId: approved.challenge.boardApiKeyId,
});
for (const companyId of companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
action: "board_api_key.created",
entityType: "user",
entityId: userId,
details: {
boardApiKeyId: approved.challenge.boardApiKeyId,
requestedAccess: approved.challenge.requestedAccess,
requestedCompanyId: approved.challenge.requestedCompanyId,
challengeId: approved.challenge.id,
},
});
}
}
res.json({
approved: approved.status === "approved",
status: approved.status,
userId,
keyId: approved.challenge.boardApiKeyId ?? null,
expiresAt: approved.challenge.expiresAt.toISOString(),
});
},
);
router.post(
"/cli-auth/challenges/:id/cancel",
validate(resolveCliAuthChallengeSchema),
async (req, res) => {
const id = (req.params.id as string).trim();
const cancelled = await boardAuth.cancelCliAuthChallenge(id, req.body.token);
res.json({
status: cancelled.status,
cancelled: cancelled.status === "cancelled",
});
},
);
router.get("/cli-auth/me", async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
const accessSnapshot = await boardAuth.resolveBoardAccess(req.actor.userId);
res.json({
user: accessSnapshot.user,
userId: req.actor.userId,
isInstanceAdmin: accessSnapshot.isInstanceAdmin,
companyIds: accessSnapshot.companyIds,
source: req.actor.source ?? "none",
keyId: req.actor.source === "board_key" ? req.actor.keyId ?? null : null,
});
});
router.post("/cli-auth/revoke-current", async (req, res) => {
if (req.actor.type !== "board" || req.actor.source !== "board_key") {
throw badRequest("Current board API key context is required");
}
const key = await boardAuth.assertCurrentBoardKey(
req.actor.keyId,
req.actor.userId,
);
await boardAuth.revokeBoardApiKey(key.id);
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
userId: key.userId,
boardApiKeyId: key.id,
});
for (const companyId of companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: key.userId,
action: "board_api_key.revoked",
entityType: "user",
entityId: key.userId,
details: {
boardApiKeyId: key.id,
revokedVia: "cli_auth_logout",
},
});
}
res.json({ revoked: true, keyId: key.id });
});
async function assertCompanyPermission(
req: Request,
companyId: string,

View file

@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import {
agentSkillSyncSchema,
createAgentKeySchema,
createAgentHireSchema,
createAgentSchema,
@ -12,30 +13,42 @@ import {
isUuidLike,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
type AgentSkillSnapshot,
type InstanceSchedulerHeartbeatAgent,
upsertAgentInstructionsFileSchema,
updateAgentInstructionsBundleSchema,
updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema,
wakeAgentSchema,
updateAgentSchema,
} from "@paperclipai/shared";
import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { validate } from "../middleware/validate.js";
import {
agentService,
agentInstructionsService,
accessService,
approvalService,
companySkillService,
budgetService,
heartbeatService,
issueApprovalService,
issueService,
logActivity,
secretService,
syncInstructionsBundleConfigFromFilePath,
workspaceOperationService,
} from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
@ -44,6 +57,10 @@ import {
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
import {
loadDefaultAgentInstructionsBundle,
resolveDefaultAgentInstructionsBundleRole,
} from "../services/default-agent-instructions.js";
export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
@ -52,8 +69,17 @@ export function agentRoutes(db: Db) {
gemini_local: "instructionsFilePath",
opencode_local: "instructionsFilePath",
cursor: "instructionsFilePath",
pi_local: "instructionsFilePath",
};
const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS));
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [
"instructionsBundleMode",
"instructionsRootPath",
"instructionsEntryFile",
"instructionsFilePath",
"agentsMdPath",
] as const;
const router = Router();
const svc = agentService(db);
@ -63,9 +89,18 @@ export function agentRoutes(db: Db) {
const heartbeat = heartbeatService(db);
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
const instructions = agentInstructionsService();
const companySkills = companySkillService(db);
const workspaceOperations = workspaceOperationService(db);
const instanceSettings = instanceSettingsService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function getCurrentUserRedactionOptions() {
return {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
}
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
@ -206,6 +241,17 @@ export function agentRoutes(db: Db) {
throw forbidden("Only CEO or agent creators can modify other agents");
}
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
throw forbidden("Agent key cannot access another company");
}
}
async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
const companyIdQuery = req.query.companyId;
const requestedCompanyId =
@ -264,6 +310,24 @@ export function agentRoutes(db: Db) {
return trimmed.length > 0 ? trimmed : null;
}
function preserveInstructionsBundleConfig(
existingAdapterConfig: Record<string, unknown>,
nextAdapterConfig: Record<string, unknown>,
) {
const nextKeys = new Set(Object.keys(nextAdapterConfig));
if (KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => nextKeys.has(key))) {
return nextAdapterConfig;
}
const merged = { ...nextAdapterConfig };
for (const key of KNOWN_INSTRUCTIONS_BUNDLE_KEYS) {
if (merged[key] === undefined && existingAdapterConfig[key] !== undefined) {
merged[key] = existingAdapterConfig[key];
}
}
return merged;
}
function parseBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
@ -378,6 +442,47 @@ export function agentRoutes(db: Db) {
return path.resolve(cwd, trimmed);
}
async function materializeDefaultInstructionsBundleForNewAgent<T extends {
id: string;
companyId: string;
name: string;
role: string;
adapterType: string;
adapterConfig: unknown;
}>(agent: T): Promise<T> {
if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) {
return agent;
}
const adapterConfig = asRecord(agent.adapterConfig) ?? {};
const hasExplicitInstructionsBundle =
Boolean(asNonEmptyString(adapterConfig.instructionsBundleMode))
|| Boolean(asNonEmptyString(adapterConfig.instructionsRootPath))
|| Boolean(asNonEmptyString(adapterConfig.instructionsEntryFile))
|| Boolean(asNonEmptyString(adapterConfig.instructionsFilePath))
|| Boolean(asNonEmptyString(adapterConfig.agentsMdPath));
if (hasExplicitInstructionsBundle) {
return agent;
}
const promptTemplate = typeof adapterConfig.promptTemplate === "string"
? adapterConfig.promptTemplate
: "";
const files = promptTemplate.trim().length === 0
? await loadDefaultAgentInstructionsBundle(resolveDefaultAgentInstructionsBundleRole(agent.role))
: { "AGENTS.md": promptTemplate };
const materialized = await instructions.materializeManagedBundle(
agent,
files,
{ entryFile: "AGENTS.md", replaceExisting: false },
);
const nextAdapterConfig = { ...materialized.adapterConfig };
delete nextAdapterConfig.promptTemplate;
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig });
return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig };
}
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
@ -412,6 +517,71 @@ export function agentRoutes(db: Db) {
return details;
}
function buildUnsupportedSkillSnapshot(
adapterType: string,
desiredSkills: string[] = [],
): AgentSkillSnapshot {
return {
adapterType,
supported: false,
mode: "unsupported",
desiredSkills,
entries: [],
warnings: ["This adapter does not implement skill sync yet."],
};
}
function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) {
return adapterType !== "claude_local";
}
async function buildRuntimeSkillConfig(
companyId: string,
adapterType: string,
config: Record<string, unknown>,
) {
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
});
return {
...config,
paperclipRuntimeSkills: runtimeSkillEntries,
};
}
async function resolveDesiredSkillAssignment(
companyId: string,
adapterType: string,
adapterConfig: Record<string, unknown>,
requestedDesiredSkills: string[] | undefined,
) {
if (!requestedDesiredSkills) {
return {
adapterConfig,
desiredSkills: null as string[] | null,
runtimeSkillEntries: null as Awaited<ReturnType<typeof companySkills.listRuntimeSkillEntries>> | null,
};
}
const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys(
companyId,
requestedDesiredSkills,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
});
const requiredSkills = runtimeSkillEntries
.filter((entry) => entry.required)
.map((entry) => entry.key);
const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills]));
return {
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills),
desiredSkills,
runtimeSkillEntries,
};
}
function redactForRestrictedAgentView(agent: Awaited<ReturnType<typeof svc.getById>>) {
if (!agent) return null;
return {
@ -537,6 +707,141 @@ export function agentRoutes(db: Db) {
},
);
router.get("/agents/:id/skills", async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanReadConfigurations(req, agent.companyId);
const adapter = findServerAdapter(agent.adapterType);
if (!adapter?.listSkills) {
const preference = readPaperclipSkillSyncPreference(
agent.adapterConfig as Record<string, unknown>,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
materializeMissing: false,
});
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
return;
}
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
agent.adapterConfig,
);
const runtimeSkillConfig = await buildRuntimeSkillConfig(
agent.companyId,
agent.adapterType,
runtimeConfig,
);
const snapshot = await adapter.listSkills({
agentId: agent.id,
companyId: agent.companyId,
adapterType: agent.adapterType,
config: runtimeSkillConfig,
});
res.json(snapshot);
});
router.post(
"/agents/:id/skills/sync",
validate(agentSkillSyncSchema),
async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanUpdateAgent(req, agent);
const requestedSkills = Array.from(
new Set(
(req.body.desiredSkills as string[])
.map((value) => value.trim())
.filter(Boolean),
),
);
const {
adapterConfig: nextAdapterConfig,
desiredSkills,
runtimeSkillEntries,
} = await resolveDesiredSkillAssignment(
agent.companyId,
agent.adapterType,
agent.adapterConfig as Record<string, unknown>,
requestedSkills,
);
if (!desiredSkills || !runtimeSkillEntries) {
throw unprocessable("Skill sync requires desiredSkills.");
}
const actor = getActorInfo(req);
const updated = await svc.update(agent.id, {
adapterConfig: nextAdapterConfig,
}, {
recordRevision: {
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
source: "skill-sync",
},
});
if (!updated) {
res.status(404).json({ error: "Agent not found" });
return;
}
const adapter = findServerAdapter(updated.adapterType);
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
updated.companyId,
updated.adapterConfig,
);
const runtimeSkillConfig = {
...runtimeConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
const snapshot = adapter?.syncSkills
? await adapter.syncSkills({
agentId: updated.id,
companyId: updated.companyId,
adapterType: updated.adapterType,
config: runtimeSkillConfig,
}, desiredSkills)
: adapter?.listSkills
? await adapter.listSkills({
agentId: updated.id,
companyId: updated.companyId,
adapterType: updated.adapterType,
config: runtimeSkillConfig,
})
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);
await logActivity(db, {
companyId: updated.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
action: "agent.skills_synced",
entityType: "agent",
entityId: updated.id,
agentId: actor.agentId,
runId: actor.runId,
details: {
adapterType: updated.adapterType,
desiredSkills,
mode: snapshot.mode,
supported: snapshot.supported,
entryCount: snapshot.entries.length,
warningCount: snapshot.warnings.length,
},
});
res.json(snapshot);
},
);
router.get("/companies/:companyId/agents", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@ -550,17 +855,7 @@ export function agentRoutes(db: Db) {
});
router.get("/instance/scheduler-heartbeats", async (req, res) => {
assertBoard(req);
const accessConditions = [];
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
const allowedCompanyIds = req.actor.companyIds ?? [];
if (allowedCompanyIds.length === 0) {
res.json([]);
return;
}
accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds));
}
assertInstanceAdmin(req);
const rows = await db
.select({
@ -578,7 +873,6 @@ export function agentRoutes(db: Db) {
})
.from(agentsTable)
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
.orderBy(companies.name, agentsTable.name);
const items: InstanceSchedulerHeartbeatAgent[] = rows
@ -607,7 +901,6 @@ export function agentRoutes(db: Db) {
};
})
.filter((item) =>
item.intervalSec > 0 &&
item.status !== "paused" &&
item.status !== "terminated" &&
item.status !== "pending_approval",
@ -632,6 +925,30 @@ export function agentRoutes(db: Db) {
res.json(leanTree);
});
router.get("/companies/:companyId/org.svg", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle;
const tree = await svc.orgForCompany(companyId);
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
const svg = renderOrgChartSvg(leanTree as unknown as OrgNode[], style);
res.setHeader("Content-Type", "image/svg+xml");
res.setHeader("Cache-Control", "no-cache");
res.send(svg);
});
router.get("/companies/:companyId/org.png", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle;
const tree = await svc.orgForCompany(companyId);
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
const png = await renderOrgChartPng(leanTree as unknown as OrgNode[], style);
res.setHeader("Content-Type", "image/png");
res.setHeader("Cache-Control", "no-cache");
res.send(png);
});
router.get("/companies/:companyId/agent-configurations", async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanReadConfigurations(req, companyId);
@ -839,14 +1156,25 @@ export function agentRoutes(db: Db) {
const companyId = req.params.companyId as string;
await assertCanCreateAgentsForCompany(req, companyId);
const sourceIssueIds = parseSourceIssueIds(req.body);
const { sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body;
const {
desiredSkills: requestedDesiredSkills,
sourceIssueId: _sourceIssueId,
sourceIssueIds: _sourceIssueIds,
...hireInput
} = req.body;
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
hireInput.adapterType,
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
);
const desiredSkillAssignment = await resolveDesiredSkillAssignment(
companyId,
hireInput.adapterType,
requestedAdapterConfig,
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
companyId,
requestedAdapterConfig,
desiredSkillAssignment.adapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
@ -871,12 +1199,13 @@ export function agentRoutes(db: Db) {
const requiresApproval = company.requireBoardApprovalForNewAgents;
const status = requiresApproval ? "pending_approval" : "idle";
const agent = await svc.create(companyId, {
const createdAgent = await svc.create(companyId, {
...normalizedHireInput,
status,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
const actor = getActorInfo(req);
@ -885,7 +1214,7 @@ export function agentRoutes(db: Db) {
const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType;
const requestedAdapterConfig =
redactEventPayload(
(normalizedHireInput.adapterConfig ?? agent.adapterConfig) as Record<string, unknown>,
(agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record<string, unknown>,
) ?? {};
const requestedRuntimeConfig =
redactEventPayload(
@ -914,6 +1243,7 @@ export function agentRoutes(db: Db) {
typeof normalizedHireInput.budgetMonthlyCents === "number"
? normalizedHireInput.budgetMonthlyCents
: agent.budgetMonthlyCents,
desiredSkills: desiredSkillAssignment.desiredSkills,
metadata: requestedMetadata,
agentId: agent.id,
requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null,
@ -921,6 +1251,7 @@ export function agentRoutes(db: Db) {
adapterType: requestedAdapterType,
adapterConfig: requestedAdapterConfig,
runtimeConfig: requestedRuntimeConfig,
desiredSkills: desiredSkillAssignment.desiredSkills,
},
},
decisionNote: null,
@ -952,6 +1283,7 @@ export function agentRoutes(db: Db) {
requiresApproval,
approvalId: approval?.id ?? null,
issueIds: sourceIssueIds,
desiredSkills: desiredSkillAssignment.desiredSkills,
},
});
@ -986,28 +1318,39 @@ export function agentRoutes(db: Db) {
assertBoard(req);
}
const {
desiredSkills: requestedDesiredSkills,
...createInput
} = req.body;
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
req.body.adapterType,
((req.body.adapterConfig ?? {}) as Record<string, unknown>),
createInput.adapterType,
((createInput.adapterConfig ?? {}) as Record<string, unknown>),
);
const desiredSkillAssignment = await resolveDesiredSkillAssignment(
companyId,
createInput.adapterType,
requestedAdapterConfig,
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
companyId,
requestedAdapterConfig,
desiredSkillAssignment.adapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
companyId,
req.body.adapterType,
createInput.adapterType,
normalizedAdapterConfig,
);
const agent = await svc.create(companyId, {
...req.body,
const createdAgent = await svc.create(companyId, {
...createInput,
adapterConfig: normalizedAdapterConfig,
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
const actor = getActorInfo(req);
await logActivity(db, {
@ -1019,7 +1362,11 @@ export function agentRoutes(db: Db) {
action: "agent.created",
entityType: "agent",
entityId: agent.id,
details: { name: agent.name, role: agent.role },
details: {
name: agent.name,
role: agent.role,
desiredSkills: desiredSkillAssignment.desiredSkills,
},
});
await applyDefaultAgentTaskAssignGrant(
@ -1130,9 +1477,10 @@ export function agentRoutes(db: Db) {
nextAdapterConfig[adapterConfigKey] = resolveInstructionsFilePath(req.body.path, existingAdapterConfig);
}
const syncedAdapterConfig = syncInstructionsBundleConfigFromFilePath(existing, nextAdapterConfig);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
nextAdapterConfig,
syncedAdapterConfig,
{ strictMode: strictSecretsMode },
);
const actor = getActorInfo(req);
@ -1179,6 +1527,166 @@ export function agentRoutes(db: Db) {
});
});
router.get("/agents/:id/instructions-bundle", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanReadAgent(req, existing);
res.json(await instructions.getBundle(existing));
});
router.patch("/agents/:id/instructions-bundle", validate(updateAgentInstructionsBundleSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanManageInstructionsPath(req, existing);
const actor = getActorInfo(req);
const { bundle, adapterConfig } = await instructions.updateBundle(existing, req.body);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
adapterConfig,
{ strictMode: strictSecretsMode },
);
await svc.update(
id,
{ adapterConfig: normalizedAdapterConfig },
{
recordRevision: {
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
source: "instructions_bundle_patch",
},
},
);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "agent.instructions_bundle_updated",
entityType: "agent",
entityId: existing.id,
details: {
mode: bundle.mode,
rootPath: bundle.rootPath,
entryFile: bundle.entryFile,
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true,
},
});
res.json(bundle);
});
router.get("/agents/:id/instructions-bundle/file", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanReadAgent(req, existing);
const relativePath = typeof req.query.path === "string" ? req.query.path : "";
if (!relativePath.trim()) {
res.status(422).json({ error: "Query parameter 'path' is required" });
return;
}
res.json(await instructions.readFile(existing, relativePath));
});
router.put("/agents/:id/instructions-bundle/file", validate(upsertAgentInstructionsFileSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanManageInstructionsPath(req, existing);
const actor = getActorInfo(req);
const result = await instructions.writeFile(existing, req.body.path, req.body.content, {
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate,
});
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
result.adapterConfig,
{ strictMode: strictSecretsMode },
);
await svc.update(
id,
{ adapterConfig: normalizedAdapterConfig },
{
recordRevision: {
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
source: "instructions_bundle_file_put",
},
},
);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "agent.instructions_file_updated",
entityType: "agent",
entityId: existing.id,
details: {
path: result.file.path,
size: result.file.size,
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true,
},
});
res.json(result.file);
});
router.delete("/agents/:id/instructions-bundle/file", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanManageInstructionsPath(req, existing);
const relativePath = typeof req.query.path === "string" ? req.query.path : "";
if (!relativePath.trim()) {
res.status(422).json({ error: "Query parameter 'path' is required" });
return;
}
const actor = getActorInfo(req);
const result = await instructions.deleteFile(existing, relativePath);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "agent.instructions_file_deleted",
entityType: "agent",
entityId: existing.id,
details: {
path: relativePath,
},
});
res.json(result.bundle);
});
router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
@ -1194,6 +1702,8 @@ export function agentRoutes(db: Db) {
}
const patchData = { ...(req.body as Record<string, unknown>) };
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
delete patchData.replaceAdapterConfig;
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
const adapterConfig = asRecord(patchData.adapterConfig);
if (!adapterConfig) {
@ -1215,9 +1725,31 @@ export function agentRoutes(db: Db) {
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
if (touchesAdapterConfiguration) {
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
const changingAdapterType =
typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType;
const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.adapterConfig) ?? {});
: null;
if (
requestedAdapterConfig
&& replaceAdapterConfig
&& KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) =>
existingAdapterConfig[key] !== undefined && requestedAdapterConfig[key] === undefined,
)
) {
await assertCanManageInstructionsPath(req, existing);
}
let rawEffectiveAdapterConfig = requestedAdapterConfig ?? existingAdapterConfig;
if (requestedAdapterConfig && !changingAdapterType && !replaceAdapterConfig) {
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig };
}
if (changingAdapterType) {
rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
existingAdapterConfig,
rawEffectiveAdapterConfig,
);
}
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
requestedAdapterType,
rawEffectiveAdapterConfig,
@ -1227,7 +1759,7 @@ export function agentRoutes(db: Db) {
effectiveAdapterConfig,
{ strictMode: strictSecretsMode },
);
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig);
}
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
@ -1597,7 +2129,7 @@ export function agentRoutes(db: Db) {
return;
}
assertCompanyAccess(req, run.companyId);
res.json(redactCurrentUserValue(run));
res.json(redactCurrentUserValue(run, await getCurrentUserRedactionOptions()));
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
@ -1632,11 +2164,12 @@ export function agentRoutes(db: Db) {
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const redactedEvents = events.map((event) =>
redactCurrentUserValue({
...event,
payload: redactEventPayload(event.payload),
}),
}, currentUserRedactionOptions),
);
res.json(redactedEvents);
});
@ -1672,7 +2205,7 @@ export function agentRoutes(db: Db) {
const context = asRecord(run.contextSnapshot);
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId);
res.json(redactCurrentUserValue(operations));
res.json(redactCurrentUserValue(operations, await getCurrentUserRedactionOptions()));
});
router.get("/workspace-operations/:operationId/log", async (req, res) => {
@ -1768,7 +2301,7 @@ export function agentRoutes(db: Db) {
}
res.json({
...redactCurrentUserValue(run),
...redactCurrentUserValue(run, await getCurrentUserRedactionOptions()),
agentId: agent.id,
agentName: agent.name,
adapterType: agent.adapterType,

View file

@ -7,6 +7,14 @@ export function assertBoard(req: Request) {
}
}
export function assertInstanceAdmin(req: Request) {
assertBoard(req);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return;
}
throw forbidden("Instance admin access required");
}
export function assertCompanyAccess(req: Request, companyId: string) {
if (req.actor.type === "none") {
throw unauthorized();

View file

@ -1,12 +1,12 @@
import { Router } from "express";
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
companyPortabilityExportSchema,
companyPortabilityImportSchema,
companyPortabilityPreviewSchema,
createCompanySchema,
updateCompanySchema,
updateCompanyBrandingSchema,
updateCompanySchema,
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
@ -18,15 +18,45 @@ import {
companyService,
logActivity,
} from "../services/index.js";
import type { StorageService } from "../storage/types.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function companyRoutes(db: Db) {
export function companyRoutes(db: Db, storage?: StorageService) {
const router = Router();
const svc = companyService(db);
const portability = companyPortabilityService(db);
const agents = agentService(db);
const portability = companyPortabilityService(db, storage);
const access = accessService(db);
const budgets = budgetService(db);
async function assertCanUpdateBranding(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role !== "ceo") {
throw forbidden("Only CEO agents can update company branding");
}
}
async function assertCanManagePortability(req: Request, companyId: string, capability: "imports" | "exports") {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role !== "ceo") {
throw forbidden(`Only CEO agents can manage company ${capability}`);
}
}
router.get("/", async (req, res) => {
assertBoard(req);
const result = await svc.list();
@ -82,20 +112,18 @@ export function companyRoutes(db: Db) {
});
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
assertBoard(req);
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId);
} else {
assertBoard(req);
}
const preview = await portability.previewImport(req.body);
res.json(preview);
});
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
assertBoard(req);
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId);
} else {
assertBoard(req);
}
const actor = getActorInfo(req);
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
@ -118,6 +146,70 @@ export function companyRoutes(db: Db) {
res.json(result);
});
router.post("/:companyId/exports/preview", validate(companyPortabilityExportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanManagePortability(req, companyId, "exports");
const preview = await portability.previewExport(companyId, req.body);
res.json(preview);
});
router.post("/:companyId/exports", validate(companyPortabilityExportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanManagePortability(req, companyId, "exports");
const result = await portability.exportBundle(companyId, req.body);
res.json(result);
});
router.post("/:companyId/imports/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanManagePortability(req, companyId, "imports");
if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) {
throw forbidden("Safe import route can only target the route company");
}
if (req.body.collisionStrategy === "replace") {
throw forbidden("Safe import route does not allow replace collision strategy");
}
const preview = await portability.previewImport(req.body, {
mode: "agent_safe",
sourceCompanyId: companyId,
});
res.json(preview);
});
router.post("/:companyId/imports/apply", validate(companyPortabilityImportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanManagePortability(req, companyId, "imports");
if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) {
throw forbidden("Safe import route can only target the route company");
}
if (req.body.collisionStrategy === "replace") {
throw forbidden("Safe import route does not allow replace collision strategy");
}
const actor = getActorInfo(req);
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null, {
mode: "agent_safe",
sourceCompanyId: companyId,
});
await logActivity(db, {
companyId: result.company.id,
actorType: actor.actorType,
actorId: actor.actorId,
entityType: "company",
entityId: result.company.id,
agentId: actor.agentId,
runId: actor.runId,
action: "company.imported",
details: {
include: req.body.include ?? null,
agentCount: result.agents.length,
warningCount: result.warnings.length,
companyAction: result.company.action,
importMode: "agent_safe",
},
});
res.json(result);
});
router.post("/", validate(createCompanySchema), async (req, res) => {
assertBoard(req);
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
@ -191,6 +283,29 @@ export function companyRoutes(db: Db) {
res.json(company);
});
router.patch("/:companyId/branding", validate(updateCompanyBrandingSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanUpdateBranding(req, companyId);
const company = await svc.update(companyId, req.body);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.branding_updated",
entityType: "company",
entityId: companyId,
details: req.body,
});
res.json(company);
});
router.post("/:companyId/archive", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;

View file

@ -0,0 +1,283 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
companySkillCreateSchema,
companySkillFileUpdateSchema,
companySkillImportSchema,
companySkillProjectScanRequestSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { accessService, agentService, companySkillService, logActivity } from "../services/index.js";
import { forbidden } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
export function companySkillRoutes(db: Db) {
const router = Router();
const agents = agentService(db);
const access = accessService(db);
const svc = companySkillService(db);
function canCreateAgents(agent: { permissions: Record<string, unknown> | null | undefined }) {
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
}
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
if (!allowed) {
throw forbidden("Missing permission: agents:create");
}
return;
}
if (!req.actor.agentId) {
throw forbidden("Agent authentication required");
}
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
if (allowedByGrant || canCreateAgents(actorAgent)) {
return;
}
throw forbidden("Missing permission: can create agents");
}
router.get("/companies/:companyId/skills", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result);
});
router.get("/companies/:companyId/skills/:skillId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.detail(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
res.json(result);
});
router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.updateStatus(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
res.json(result);
});
router.get("/companies/:companyId/skills/:skillId/files", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
const relativePath = String(req.query.path ?? "SKILL.md");
assertCompanyAccess(req, companyId);
const result = await svc.readFile(companyId, skillId, relativePath);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
res.json(result);
});
router.post(
"/companies/:companyId/skills",
validate(companySkillCreateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.createLocalSkill(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_created",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
name: result.name,
},
});
res.status(201).json(result);
},
);
router.patch(
"/companies/:companyId/skills/:skillId/files",
validate(companySkillFileUpdateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.updateFile(
companyId,
skillId,
String(req.body.path ?? ""),
String(req.body.content ?? ""),
);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_file_updated",
entityType: "company_skill",
entityId: skillId,
details: {
path: result.path,
markdown: result.markdown,
},
});
res.json(result);
},
);
router.post(
"/companies/:companyId/skills/import",
validate(companySkillImportSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const source = String(req.body.source ?? "");
const result = await svc.importFromSource(companyId, source);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skills_imported",
entityType: "company",
entityId: companyId,
details: {
source,
importedCount: result.imported.length,
importedSlugs: result.imported.map((skill) => skill.slug),
warningCount: result.warnings.length,
},
});
res.status(201).json(result);
},
);
router.post(
"/companies/:companyId/skills/scan-projects",
validate(companySkillProjectScanRequestSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.scanProjectWorkspaces(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skills_scanned",
entityType: "company",
entityId: companyId,
details: {
scannedProjects: result.scannedProjects,
scannedWorkspaces: result.scannedWorkspaces,
discovered: result.discovered,
importedCount: result.imported.length,
updatedCount: result.updated.length,
conflictCount: result.conflicts.length,
warningCount: result.warnings.length,
},
});
res.json(result);
},
);
router.delete("/companies/:companyId/skills/:skillId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.deleteSkill(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_deleted",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
name: result.name,
},
});
res.json(result);
});
router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.installUpdate(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_update_installed",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
sourceRef: result.sourceRef,
},
});
res.json(result);
});
return router;
}

View file

@ -103,9 +103,9 @@ export function costRoutes(db: Db) {
}
function parseLimit(query: Record<string, unknown>) {
const raw = query.limit as string | undefined;
if (!raw) return 100;
const limit = Number.parseInt(raw, 10);
const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit;
if (raw == null || raw === "") return 100;
const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
throw badRequest("invalid 'limit' value");
}

View file

@ -1,8 +1,10 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { and, count, eq, gt, isNull, sql } from "drizzle-orm";
import { instanceUserRoles, invites } from "@paperclipai/db";
import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { serverVersion } from "../version.js";
export function healthRoutes(
@ -55,6 +57,23 @@ export function healthRoutes(
}
}
const persistedDevServerStatus = readPersistedDevServerStatus();
let devServer: ReturnType<typeof toDevServerHealthStatus> | undefined;
if (persistedDevServerStatus) {
const instanceSettings = instanceSettingsService(db);
const experimentalSettings = await instanceSettings.getExperimental();
const activeRunCount = await db
.select({ count: count() })
.from(heartbeatRuns)
.where(inArray(heartbeatRuns.status, ["queued", "running"]))
.then((rows) => Number(rows[0]?.count ?? 0));
devServer = toDevServerHealthStatus(persistedDevServerStatus, {
autoRestartEnabled: experimentalSettings.autoRestartDevServerWhenIdle ?? false,
activeRunCount,
});
}
res.json({
status: "ok",
version: serverVersion,
@ -66,6 +85,7 @@ export function healthRoutes(
features: {
companyDeletionEnabled: opts.companyDeletionEnabled,
},
...(devServer ? { devServer } : {}),
});
});

View file

@ -1,8 +1,10 @@
export { healthRoutes } from "./health.js";
export { companyRoutes } from "./companies.js";
export { companySkillRoutes } from "./company-skills.js";
export { agentRoutes } from "./agents.js";
export { projectRoutes } from "./projects.js";
export { issueRoutes } from "./issues.js";
export { routineRoutes } from "./routines.js";
export { goalRoutes } from "./goals.js";
export { approvalRoutes } from "./approvals.js";
export { secretRoutes } from "./secrets.js";

View file

@ -1,6 +1,6 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import { patchInstanceExperimentalSettingsSchema } from "@paperclipai/shared";
import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSchema } from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { instanceSettingsService, logActivity } from "../services/index.js";
@ -20,6 +20,41 @@ export function instanceSettingsRoutes(db: Db) {
const router = Router();
const svc = instanceSettingsService(db);
router.get("/instance/settings/general", async (req, res) => {
assertCanManageInstanceSettings(req);
res.json(await svc.getGeneral());
});
router.patch(
"/instance/settings/general",
validate(patchInstanceGeneralSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
const updated = await svc.updateGeneral(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "instance.settings.general_updated",
entityType: "instance_settings",
entityId: updated.id,
details: {
general: updated.general,
changedKeys: Object.keys(req.body).sort(),
},
}),
),
);
res.json(updated.general);
},
);
router.get("/instance/settings/experimental", async (req, res) => {
assertCanManageInstanceSettings(req);
res.json(await svc.getExperimental());

View file

@ -27,6 +27,7 @@ import {
documentService,
logActivity,
projectService,
routineService,
workProductService,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
@ -34,6 +35,7 @@ import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
@ -49,6 +51,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const executionWorkspacesSvc = executionWorkspaceService(db);
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const routinesSvc = routineService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
@ -230,12 +233,17 @@ export function issueRoutes(db: Db, storage: StorageService) {
const result = await svc.list(companyId, {
status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
participantAgentId: req.query.participantAgentId as string | undefined,
assigneeUserId,
touchedByUserId,
unreadForUserId,
projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId as string | undefined,
labelId: req.query.labelId as string | undefined,
originKind: req.query.originKind as string | undefined,
originId: req.query.originId as string | undefined,
includeRoutineExecutions:
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
q: req.query.q as string | undefined,
});
res.json(result);
@ -775,19 +783,15 @@ export function issueRoutes(db: Db, storage: StorageService) {
details: { title: issue.title, identifier: issue.identifier },
});
if (issue.assigneeAgentId && issue.status !== "backlog") {
void heartbeat
.wakeup(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId: issue.id, mutation: "create" },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.create" },
})
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue create"));
}
void queueIssueAssignmentWakeup({
heartbeat,
issue,
reason: "issue_assigned",
mutation: "create",
contextSource: "issue.create",
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
res.status(201).json(issue);
});
@ -821,10 +825,14 @@ export function issueRoutes(db: Db, storage: StorageService) {
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
const isClosed = existing.status === "done" || existing.status === "cancelled";
const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
}
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
updateFields.status = "todo";
}
let issue;
try {
issue = await svc.update(id, updateFields);
@ -856,6 +864,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(404).json({ error: "Issue not found" });
return;
}
await routinesSvc.syncRunStatusForIssue(issue.id);
if (actor.runId) {
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
@ -871,6 +880,13 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
const hasFieldChanges = Object.keys(previous).length > 0;
const reopened =
commentBody &&
reopenRequested === true &&
isClosed &&
previous.status !== undefined &&
issue.status === "todo";
const reopenFromStatus = reopened ? existing.status : null;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
@ -884,6 +900,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
...updateFields,
identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
_previous: hasFieldChanges ? previous : undefined,
},
});
@ -909,6 +926,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
bodySnippet: comment.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(hasFieldChanges ? { updated: true } : {}),
},
});

View file

@ -0,0 +1,777 @@
/**
* Server-side SVG renderer for Paperclip org charts.
* Supports 5 visual styles: monochrome, nebula, circuit, warmth, schematic.
* Pure SVG output no browser/Playwright needed. PNG via sharp.
*/
export interface OrgNode {
id: string;
name: string;
role: string;
status: string;
reports: OrgNode[];
/** Populated by collapseTree: the flattened list of hidden descendants for avatar grid rendering. */
collapsedReports?: OrgNode[];
}
export type OrgChartStyle = "monochrome" | "nebula" | "circuit" | "warmth" | "schematic";
export const ORG_CHART_STYLES: OrgChartStyle[] = ["monochrome", "nebula", "circuit", "warmth", "schematic"];
interface LayoutNode {
node: OrgNode;
x: number;
y: number;
width: number;
height: number;
children: LayoutNode[];
}
// ── Style theme definitions ──────────────────────────────────────
interface StyleTheme {
bgColor: string;
cardBg: string;
cardBorder: string;
cardRadius: number;
cardShadow: string | null;
lineColor: string;
lineWidth: number;
nameColor: string;
roleColor: string;
font: string;
watermarkColor: string;
/** Extra SVG defs (filters, patterns, gradients) */
defs: (svgW: number, svgH: number) => string;
/** Extra background elements after the main bg rect */
bgExtras: (svgW: number, svgH: number) => string;
/** Custom card renderer — if null, uses default avatar+name+role */
renderCard: ((ln: LayoutNode, theme: StyleTheme) => string) | null;
/** Per-card accent (top bar, border glow, etc.) */
cardAccent: ((tag: string) => string) | null;
}
// ── Role config with Twemoji SVG inlines (viewBox 0 0 36 36) ─────
//
// Each `emojiSvg` contains the inner SVG paths from Twemoji (CC-BY 4.0).
// These render as colorful emoji-style icons inside the avatar circle,
// without needing a browser or emoji font.
const ROLE_ICONS: Record<string, {
bg: string;
roleLabel: string;
accentColor: string;
/** Twemoji inner SVG content (paths only, viewBox 0 0 36 36) */
emojiSvg: string;
/** Fallback monochrome icon path (16×16 viewBox) for minimal rendering */
iconPath: string;
iconColor: string;
}> = {
ceo: {
bg: "#fef3c7", roleLabel: "Chief Executive", accentColor: "#f0883e", iconColor: "#92400e",
iconPath: "M8 1l2.2 4.5L15 6.2l-3.5 3.4.8 4.9L8 12.2 3.7 14.5l.8-4.9L1 6.2l4.8-.7z",
// 👑 Crown
emojiSvg: `<path fill="#F4900C" d="M14.174 17.075L6.75 7.594l-3.722 9.481z"/><path fill="#F4900C" d="M17.938 5.534l-6.563 12.389H24.5z"/><path fill="#F4900C" d="M21.826 17.075l7.424-9.481 3.722 9.481z"/><path fill="#FFCC4D" d="M28.669 15.19L23.887 3.523l-5.88 11.668-.007.003-.007-.004-5.88-11.668L7.331 15.19C4.197 10.833 1.28 8.042 1.28 8.042S3 20.75 3 33h30c0-12.25 1.72-24.958 1.72-24.958s-2.917 2.791-6.051 7.148z"/><circle fill="#5C913B" cx="17.957" cy="22" r="3.688"/><circle fill="#981CEB" cx="26.463" cy="22" r="2.412"/><circle fill="#DD2E44" cx="32.852" cy="22" r="1.986"/><circle fill="#981CEB" cx="9.45" cy="22" r="2.412"/><circle fill="#DD2E44" cx="3.061" cy="22" r="1.986"/><path fill="#FFAC33" d="M33 34H3c-.552 0-1-.447-1-1s.448-1 1-1h30c.553 0 1 .447 1 1s-.447 1-1 1zm0-3.486H3c-.552 0-1-.447-1-1s.448-1 1-1h30c.553 0 1 .447 1 1s-.447 1-1 1z"/><circle fill="#FFCC4D" cx="1.447" cy="8.042" r="1.407"/><circle fill="#F4900C" cx="6.75" cy="7.594" r="1.192"/><circle fill="#FFCC4D" cx="12.113" cy="3.523" r="1.784"/><circle fill="#FFCC4D" cx="34.553" cy="8.042" r="1.407"/><circle fill="#F4900C" cx="29.25" cy="7.594" r="1.192"/><circle fill="#FFCC4D" cx="23.887" cy="3.523" r="1.784"/><circle fill="#F4900C" cx="17.938" cy="5.534" r="1.784"/>`,
},
cto: {
bg: "#dbeafe", roleLabel: "Technology", accentColor: "#58a6ff", iconColor: "#1e40af",
iconPath: "M2 3l5 5-5 5M9 13h5",
// 💻 Laptop
emojiSvg: `<path fill="#CCD6DD" d="M34 29.096c-.417-.963-.896-2.008-2-2.008h-1c1.104 0 2-.899 2-2.008V8.008C33 6.899 32.104 6 31 6H5c-1.104 0-2 .899-2 2.008V25.08c0 1.109.896 2.008 2 2.008H4c-1.104 0-1.667 1.004-2 2.008l-2 4.895C0 35.101.896 36 2 36h32c1.104 0 2-.899 2-2.008l-2-4.896z"/><path fill="#9AAAB4" d="M.008 34.075l.006.057.17.692C.5 35.516 1.192 36 2 36h32c1.076 0 1.947-.855 1.992-1.925H.008z"/><path fill="#5DADEC" d="M31 24.075c0 .555-.447 1.004-1 1.004H6c-.552 0-1-.449-1-1.004V9.013c0-.555.448-1.004 1-1.004h24c.553 0 1 .45 1 1.004v15.062z"/><path fill="#AEBBC1" d="M32.906 31.042l-.76-2.175c-.239-.46-.635-.837-1.188-.837H5.11c-.552 0-.906.408-1.156 1.036l-.688 1.977c-.219.596.448 1.004 1 1.004h7.578s.937-.047 1.103-.608c.192-.648.415-1.624.463-1.796.074-.264.388-.531.856-.531h8.578c.5 0 .746.253.811.566.042.204.312 1.141.438 1.782.111.571 1.221.586 1.221.586h6.594c.551 0 1.217-.471.998-1.004z"/><path fill="#9AAAB4" d="M22.375 33.113h-7.781c-.375 0-.538-.343-.484-.675.054-.331.359-1.793.383-1.963.023-.171.274-.375.524-.375h7.015c.297 0 .49.163.55.489.059.327.302 1.641.321 1.941.019.301-.169.583-.528.583z"/>`,
},
cmo: {
bg: "#dcfce7", roleLabel: "Marketing", accentColor: "#3fb950", iconColor: "#166534",
iconPath: "M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM1 8h14M8 1c-2 2-3 4.5-3 7s1 5 3 7c2-2 3-4.5 3-7s-1-5-3-7z",
// 🌐 Globe with meridians
emojiSvg: `<path fill="#3B88C3" d="M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zM2.05 19h3.983c.092 2.506.522 4.871 1.229 7H4.158c-1.207-2.083-1.95-4.459-2.108-7zM19 8V2.081c2.747.436 5.162 2.655 6.799 5.919H19zm7.651 2c.754 2.083 1.219 4.46 1.317 7H19v-7h7.651zM17 2.081V8h-6.799C11.837 4.736 14.253 2.517 17 2.081zM17 10v7H8.032c.098-2.54.563-4.917 1.317-7H17zM6.034 17H2.05c.158-2.54.901-4.917 2.107-7h3.104c-.705 2.129-1.135 4.495-1.227 7zm1.998 2H17v7H9.349c-.754-2.083-1.219-4.459-1.317-7zM17 28v5.919c-2.747-.437-5.163-2.655-6.799-5.919H17zm2 5.919V28h6.8c-1.637 3.264-4.053 5.482-6.8 5.919zM19 26v-7h8.969c-.099 2.541-.563 4.917-1.317 7H19zm10.967-7h3.982c-.157 2.541-.9 4.917-2.107 7h-3.104c.706-2.129 1.136-4.494 1.229-7zm0-2c-.093-2.505-.523-4.871-1.229-7h3.104c1.207 2.083 1.95 4.46 2.107 7h-3.982zm.512-9h-2.503c-.717-1.604-1.606-3.015-2.619-4.199C27.346 4.833 29.089 6.267 30.479 8zM10.643 3.801C9.629 4.985 8.74 6.396 8.023 8H5.521c1.39-1.733 3.133-3.166 5.122-4.199zM5.521 28h2.503c.716 1.604 1.605 3.015 2.619 4.198C8.654 31.166 6.911 29.733 5.521 28zm19.836 4.198c1.014-1.184 1.902-2.594 2.619-4.198h2.503c-1.39 1.733-3.133 3.166-5.122 4.198z"/>`,
},
cfo: {
bg: "#fef3c7", roleLabel: "Finance", accentColor: "#f0883e", iconColor: "#92400e",
iconPath: "M8 1v14M5 4.5C5 3.1 6.3 2 8 2s3 1.1 3 2.5S9.7 7 8 7 5 8.1 5 9.5 6.3 12 8 12s3-1.1 3-2.5",
// 📊 Bar chart
emojiSvg: `<path fill="#CCD6DD" d="M31 2H5C3.343 2 2 3.343 2 5v26c0 1.657 1.343 3 3 3h26c1.657 0 3-1.343 3-3V5c0-1.657-1.343-3-3-3z"/><path fill="#E1E8ED" d="M31 1H5C2.791 1 1 2.791 1 5v26c0 2.209 1.791 4 4 4h26c2.209 0 4-1.791 4-4V5c0-2.209-1.791-4-4-4zm0 2c1.103 0 2 .897 2 2v4h-6V3h4zm-4 16h6v6h-6v-6zm0-2v-6h6v6h-6zM25 3v6h-6V3h6zm-6 8h6v6h-6v-6zm0 8h6v6h-6v-6zM17 3v6h-6V3h6zm-6 8h6v6h-6v-6zm0 8h6v6h-6v-6zM3 5c0-1.103.897-2 2-2h4v6H3V5zm0 6h6v6H3v-6zm0 8h6v6H3v-6zm2 14c-1.103 0-2-.897-2-2v-4h6v6H5zm6 0v-6h6v6h-6zm8 0v-6h6v6h-6zm12 0h-4v-6h6v4c0 1.103-.897 2-2 2z"/><path fill="#5C913B" d="M13 33H7V16c0-1.104.896-2 2-2h2c1.104 0 2 .896 2 2v17z"/><path fill="#3B94D9" d="M29 33h-6V9c0-1.104.896-2 2-2h2c1.104 0 2 .896 2 2v24z"/><path fill="#DD2E44" d="M21 33h-6V23c0-1.104.896-2 2-2h2c1.104 0 2 .896 2 2v10z"/>`,
},
coo: {
bg: "#e0f2fe", roleLabel: "Operations", accentColor: "#58a6ff", iconColor: "#075985",
iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z",
// ⚙️ Gear
emojiSvg: `<path fill="#66757F" d="M34 15h-3.362c-.324-1.369-.864-2.651-1.582-3.814l2.379-2.379c.781-.781.781-2.048 0-2.829l-1.414-1.414c-.781-.781-2.047-.781-2.828 0l-2.379 2.379C23.65 6.225 22.369 5.686 21 5.362V2c0-1.104-.896-2-2-2h-2c-1.104 0-2 .896-2 2v3.362c-1.369.324-2.651.864-3.814 1.582L8.808 4.565c-.781-.781-2.048-.781-2.828 0L4.565 5.979c-.781.781-.781 2.048-.001 2.829l2.379 2.379C6.225 12.35 5.686 13.632 5.362 15H2c-1.104 0-2 .896-2 2v2c0 1.104.896 2 2 2h3.362c.324 1.368.864 2.65 1.582 3.813l-2.379 2.379c-.78.78-.78 2.048.001 2.829l1.414 1.414c.78.78 2.047.78 2.828 0l2.379-2.379c1.163.719 2.445 1.258 3.814 1.582V34c0 1.104.896 2 2 2h2c1.104 0 2-.896 2-2v-3.362c1.368-.324 2.65-.864 3.813-1.582l2.379 2.379c.781.781 2.047.781 2.828 0l1.414-1.414c.781-.781.781-2.048 0-2.829l-2.379-2.379c.719-1.163 1.258-2.445 1.582-3.814H34c1.104 0 2-.896 2-2v-2C36 15.896 35.104 15 34 15zM18 26c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8z"/>`,
},
engineer: {
bg: "#f3e8ff", roleLabel: "Engineering", accentColor: "#bc8cff", iconColor: "#6b21a8",
iconPath: "M5 3L1 8l4 5M11 3l4 5-4 5",
// ⌨️ Keyboard
emojiSvg: `<path fill="#99AAB5" d="M36 28c0 1.104-.896 2-2 2H2c-1.104 0-2-.896-2-2V12c0-1.104.896-2 2-2h32c1.104 0 2 .896 2 2v16z"/><path d="M5.5 19c0 .553-.448 1-1 1h-1c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h1c.552 0 1 .447 1 1v1zm4 0c0 .553-.448 1-1 1h-1c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h1c.552 0 1 .447 1 1v1zm4 0c0 .553-.448 1-1 1h-1c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h1c.552 0 1 .447 1 1v1zm4 0c0 .553-.448 1-1 1h-1c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h1c.552 0 1 .447 1 1v1zm4 0c0 .553-.447 1-1 1h-1c-.553 0-1-.447-1-1v-1c0-.553.447-1 1-1h1c.553 0 1 .447 1 1v1zm4 0c0 .553-.447 1-1 1h-1c-.553 0-1-.447-1-1v-1c0-.553.447-1 1-1h1c.553 0 1 .447 1 1v1zm4 0c0 .553-.447 1-1 1h-1c-.553 0-1-.447-1-1v-1c0-.553.447-1 1-1h1c.553 0 1 .447 1 1v1zm4 0c0 .553-.447 1-1 1h-1c-.553 0-1-.447-1-1v-1c0-.553.447-1 1-1h1c.553 0 1 .447 1 1v1zm-26 4c0 .553-.448 1-1 1h-1c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h1c.552 0 1 .447 1 1v1zm4 0c0 .553-.448 1-1 1h-1c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h1c.552 0 1 .447 1 1v1zm4 0c0 .553-.448 1-1 1h-1c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h1c.552 0 1 .447 1 1v1zm4 0c0 .553-.447 1-1 1h-1c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h1c.553 0 1 .447 1 1v1zm4 0c0 .553-.447 1-1 1h-1c-.553 0-1-.447-1-1v-1c0-.553.447-1 1-1h1c.553 0 1 .447 1 1v1zm4 0c0 .553-.447 1-1 1h-1c-.553 0-1-.447-1-1v-1c0-.553.447-1 1-1h1c.553 0 1 .447 1 1v1zm4 0c0 .553-.447 1-1 1h-1c-.553 0-1-.447-1-1v-1c0-.553.447-1 1-1h1c.553 0 1 .447 1 1v1zM10 27c0 .553-.448 1-1 1H7c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h2c.552 0 1 .447 1 1v1zm20 0c0 .553-.447 1-1 1h-2c-.553 0-1-.447-1-1v-1c0-.553.447-1 1-1h2c.553 0 1 .447 1 1v1zm-5 0c0 .553-.447 1-1 1H12c-.552 0-1-.447-1-1v-1c0-.553.448-1 1-1h12c.553 0 1 .447 1 1v1zM5.5 13.083c0 .552-.448 1-1 1h-1c-.552 0-1-.448-1-1s.448-1 1-1h1c.552 0 1 .448 1 1zm4 0c0 .552-.448 1-1 1h-1c-.552 0-1-.448-1-1s.448-1 1-1h1c.552 0 1 .448 1 1zm4 0c0 .552-.448 1-1 1h-1c-.552 0-1-.448-1-1s.448-1 1-1h1c.552 0 1 .448 1 1zm4 0c0 .552-.448 1-1 1h-1c-.552 0-1-.448-1-1s.448-1 1-1h1c.552 0 1 .448 1 1zm4 0c0 .552-.447 1-1 1h-1c-.553 0-1-.448-1-1s.447-1 1-1h1c.553 0 1 .448 1 1zm4 0c0 .552-.447 1-1 1h-1c-.553 0-1-.448-1-1s.447-1 1-1h1c.553 0 1 .448 1 1zm4 0c0 .552-.447 1-1 1h-1c-.553 0-1-.448-1-1s.447-1 1-1h1c.553 0 1 .448 1 1zm4 0c0 .552-.447 1-1 1h-1c-.553 0-1-.448-1-1s.447-1 1-1h1c.553 0 1 .448 1 1z" fill="#292F33"/>`,
},
quality: {
bg: "#ffe4e6", roleLabel: "Quality", accentColor: "#f778ba", iconColor: "#9f1239",
iconPath: "M4 8l3 3 5-6M8 1L2 4v4c0 3.5 2.6 6.8 6 8 3.4-1.2 6-4.5 6-8V4z",
// 🔬 Microscope
emojiSvg: `<g fill="#66757F"><path d="M19.78 21.345l-6.341-6.342-.389 4.38 2.35 2.351z"/><path d="M15.4 22.233c-.132 0-.259-.053-.354-.146l-2.351-2.351c-.104-.104-.158-.25-.145-.397l.389-4.38c.017-.193.145-.359.327-.425.182-.067.388-.021.524.116l6.341 6.342c.138.138.183.342.116.524s-.232.31-.426.327l-4.379.389-.042.001zm-1.832-3.039l2.021 2.021 3.081-.273-4.828-4.828-.274 3.08z"/></g><path fill="#8899A6" d="M31 32h-3c0-3.314-2.63-6-5.875-6-3.244 0-5.875 2.686-5.875 6H8.73c0-1.104-.895-2-2-2-1.104 0-2 .896-2 2-1.104 0-2 .896-2 2s.896 2 2 2H31c1.104 0 2-.896 2-2s-.896-2-2-2z"/><path fill="#8899A6" d="M20 10v4c3.866 0 7 3.134 7 7s-3.134 7-7 7h-8.485c2.018 2.443 5.069 4 8.485 4 6.075 0 11-4.925 11-11s-4.925-11-11-11z"/><path fill="#67757F" d="M16.414 30.414c-.781.781-2.047.781-2.828 0l-9.899-9.9c-.781-.781-.781-2.047 0-2.828.781-.781 2.047-.781 2.829 0l9.899 9.9c.78.781.78 2.047-.001 2.828zm-7.225-1.786c.547-.077 1.052.304 1.129.851.077.547-.305 1.053-.851 1.129l-5.942.834c-.547.077-1.052-.305-1.129-.851-.077-.547.305-1.053.852-1.13l5.941-.833z"/><path fill="#66757F" d="M27.341 2.98l4.461 4.461-3.806 3.807-4.461-4.461z"/><path fill="#AAB8C2" d="M34.037 7.083c-.827.827-2.17.827-2.997 0l-3.339-3.34c-.827-.826-.827-2.169 0-2.996.827-.826 2.17-.826 2.995 0l3.342 3.34c.826.827.826 2.168-.001 2.996zm-14.56 15.026l-6.802-6.803c-.389-.389-.389-1.025 0-1.414l9.858-9.858c.389-.389 1.025-.389 1.414 0l6.801 6.803c.389.389.389 1.025 0 1.414l-9.858 9.858c-.388.389-1.024.389-1.413 0z"/><path fill="#E1E8ED" d="M13.766 12.8l1.638-1.637 8.216 8.216-1.638 1.637z"/>`,
},
design: {
bg: "#fce7f3", roleLabel: "Design", accentColor: "#79c0ff", iconColor: "#9d174d",
iconPath: "M12 2l2 2-9 9H3v-2zM9.5 4.5l2 2",
// 🪄 Magic wand
emojiSvg: `<path fill="#292F33" d="M3.651 29.852L29.926 3.576c.391-.391 2.888 2.107 2.497 2.497L6.148 32.349c-.39.391-2.888-2.107-2.497-2.497z"/><path fill="#66757F" d="M30.442 4.051L4.146 30.347l.883.883L31.325 4.934z"/><path fill="#E1E8ED" d="M34.546 2.537l-.412-.412-.671-.671c-.075-.075-.165-.123-.255-.169-.376-.194-.844-.146-1.159.169l-2.102 2.102.495.495.883.883 1.119 1.119 2.102-2.102c.391-.391.391-1.024 0-1.414zM5.029 31.23l-.883-.883-.495-.495-2.209 2.208c-.315.315-.363.783-.169 1.159.046.09.094.18.169.255l.671.671.412.412c.391.391 1.024.391 1.414 0l2.208-2.208-1.118-1.119z"/><path fill="#F5F8FA" d="M31.325 4.934l2.809-2.809-.671-.671c-.075-.075-.165-.123-.255-.169l-2.767 2.767.884.882zM4.146 30.347L1.273 33.22c.046.09.094.18.169.255l.671.671 2.916-2.916-.883-.883z"/><path d="M28.897 14.913l1.542-.571.6-2.2c.079-.29.343-.491.644-.491.3 0 .564.201.643.491l.6 2.2 1.542.571c.262.096.435.346.435.625s-.173.529-.435.625l-1.534.568-.605 2.415c-.074.296-.341.505-.646.505-.306 0-.573-.209-.647-.505l-.605-2.415-1.534-.568c-.262-.096-.435-.346-.435-.625 0-.278.173-.528.435-.625M11.961 5.285l2.61-.966.966-2.61c.16-.433.573-.72 1.035-.72.461 0 .874.287 1.035.72l.966 2.61 2.609.966c.434.161.721.573.721 1.035 0 .462-.287.874-.721 1.035l-2.609.966-.966 2.61c-.161.433-.574.72-1.035.72-.462 0-.875-.287-1.035-.72l-.966-2.61-2.61-.966c-.433-.161-.72-.573-.72-1.035.001-.462.288-.874.72-1.035M24.13 20.772l1.383-.512.512-1.382c.085-.229.304-.381.548-.381.244 0 .463.152.548.381l.512 1.382 1.382.512c.23.085.382.304.382.548 0 .245-.152.463-.382.548l-1.382.512-.512 1.382c-.085.229-.304.381-.548.381-.245 0-.463-.152-.548-.381l-.512-1.382-1.383-.512c-.229-.085-.381-.304-.381-.548 0-.245.152-.463.381-.548" fill="#FFAC33"/>`,
},
finance: {
bg: "#fef3c7", roleLabel: "Finance", accentColor: "#f0883e", iconColor: "#92400e",
iconPath: "M8 1v14M5 4.5C5 3.1 6.3 2 8 2s3 1.1 3 2.5S9.7 7 8 7 5 8.1 5 9.5 6.3 12 8 12s3-1.1 3-2.5",
// 📊 Bar chart (same as CFO)
emojiSvg: `<path fill="#CCD6DD" d="M31 2H5C3.343 2 2 3.343 2 5v26c0 1.657 1.343 3 3 3h26c1.657 0 3-1.343 3-3V5c0-1.657-1.343-3-3-3z"/><path fill="#E1E8ED" d="M31 1H5C2.791 1 1 2.791 1 5v26c0 2.209 1.791 4 4 4h26c2.209 0 4-1.791 4-4V5c0-2.209-1.791-4-4-4zm0 2c1.103 0 2 .897 2 2v4h-6V3h4zm-4 16h6v6h-6v-6zm0-2v-6h6v6h-6zM25 3v6h-6V3h6zm-6 8h6v6h-6v-6zm0 8h6v6h-6v-6zM17 3v6h-6V3h6zm-6 8h6v6h-6v-6zm0 8h6v6h-6v-6zM3 5c0-1.103.897-2 2-2h4v6H3V5zm0 6h6v6H3v-6zm0 8h6v6H3v-6zm2 14c-1.103 0-2-.897-2-2v-4h6v6H5zm6 0v-6h6v6h-6zm8 0v-6h6v6h-6zm12 0h-4v-6h6v4c0 1.103-.897 2-2 2z"/><path fill="#5C913B" d="M13 33H7V16c0-1.104.896-2 2-2h2c1.104 0 2 .896 2 2v17z"/><path fill="#3B94D9" d="M29 33h-6V9c0-1.104.896-2 2-2h2c1.104 0 2 .896 2 2v24z"/><path fill="#DD2E44" d="M21 33h-6V23c0-1.104.896-2 2-2h2c1.104 0 2 .896 2 2v10z"/>`,
},
operations: {
bg: "#e0f2fe", roleLabel: "Operations", accentColor: "#58a6ff", iconColor: "#075985",
iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z",
// ⚙️ Gear (same as COO)
emojiSvg: `<path fill="#66757F" d="M34 15h-3.362c-.324-1.369-.864-2.651-1.582-3.814l2.379-2.379c.781-.781.781-2.048 0-2.829l-1.414-1.414c-.781-.781-2.047-.781-2.828 0l-2.379 2.379C23.65 6.225 22.369 5.686 21 5.362V2c0-1.104-.896-2-2-2h-2c-1.104 0-2 .896-2 2v3.362c-1.369.324-2.651.864-3.814 1.582L8.808 4.565c-.781-.781-2.048-.781-2.828 0L4.565 5.979c-.781.781-.781 2.048-.001 2.829l2.379 2.379C6.225 12.35 5.686 13.632 5.362 15H2c-1.104 0-2 .896-2 2v2c0 1.104.896 2 2 2h3.362c.324 1.368.864 2.65 1.582 3.813l-2.379 2.379c-.78.78-.78 2.048.001 2.829l1.414 1.414c.78.78 2.047.78 2.828 0l2.379-2.379c1.163.719 2.445 1.258 3.814 1.582V34c0 1.104.896 2 2 2h2c1.104 0 2-.896 2-2v-3.362c1.368-.324 2.65-.864 3.813-1.582l2.379 2.379c.781.781 2.047.781 2.828 0l1.414-1.414c.781-.781.781-2.048 0-2.829l-2.379-2.379c.719-1.163 1.258-2.445 1.582-3.814H34c1.104 0 2-.896 2-2v-2C36 15.896 35.104 15 34 15zM18 26c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8z"/>`,
},
default: {
bg: "#f3e8ff", roleLabel: "Agent", accentColor: "#bc8cff", iconColor: "#6b21a8",
iconPath: "M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2 14c0-3.3 2.7-4 6-4s6 .7 6 4",
// 👤 Person silhouette
emojiSvg: `<path fill="#269" d="M24 26.799v-2.566c2-1.348 4.08-3.779 4.703-6.896.186.103.206.17.413.17.991 0 1.709-1.287 1.709-2.873 0-1.562-.823-2.827-1.794-2.865.187-.674.293-1.577.293-2.735C29.324 5.168 26 .527 18.541.527c-6.629 0-10.777 4.641-10.777 8.507 0 1.123.069 2.043.188 2.755-.911.137-1.629 1.352-1.629 2.845 0 1.587.804 2.873 1.796 2.873.206 0 .025-.067.209-.17C8.952 20.453 11 22.885 13 24.232v2.414c-5 .645-12 3.437-12 6.23v1.061C1 35 2.076 35 3.137 35h29.725C33.924 35 35 35 35 33.938v-1.061c0-2.615-6-5.225-11-6.078z"/>`,
},
};
function guessRoleTag(node: OrgNode): string {
const name = node.name.toLowerCase();
const role = node.role.toLowerCase();
if (name === "ceo" || role.includes("chief executive")) return "ceo";
if (name === "cto" || role.includes("chief technology") || role.includes("technology")) return "cto";
if (name === "cmo" || role.includes("chief marketing") || role.includes("marketing")) return "cmo";
if (name === "cfo" || role.includes("chief financial")) return "cfo";
if (name === "coo" || role.includes("chief operating")) return "coo";
if (role.includes("engineer") || role.includes("eng")) return "engineer";
if (role.includes("quality") || role.includes("qa")) return "quality";
if (role.includes("design")) return "design";
if (role.includes("finance")) return "finance";
if (role.includes("operations") || role.includes("ops")) return "operations";
return "default";
}
function getRoleInfo(node: OrgNode) {
const tag = guessRoleTag(node);
return { tag, ...(ROLE_ICONS[tag] || ROLE_ICONS.default) };
}
// ── Style themes ─────────────────────────────────────────────────
const THEMES: Record<OrgChartStyle, StyleTheme> = {
// 01 — Monochrome (Vercel-inspired, dark minimal)
monochrome: {
bgColor: "#18181b",
cardBg: "#18181b",
cardBorder: "#27272a",
cardRadius: 6,
cardShadow: null,
lineColor: "#3f3f46",
lineWidth: 1.5,
nameColor: "#fafafa",
roleColor: "#71717a",
font: "'Inter', system-ui, sans-serif",
watermarkColor: "rgba(255,255,255,0.25)",
defs: () => "",
bgExtras: () => "",
renderCard: null,
cardAccent: null,
},
// 02 — Nebula (glassmorphism on cosmic gradient)
nebula: {
bgColor: "#0f0c29",
cardBg: "rgba(255,255,255,0.07)",
cardBorder: "rgba(255,255,255,0.12)",
cardRadius: 6,
cardShadow: null,
lineColor: "rgba(255,255,255,0.25)",
lineWidth: 1.5,
nameColor: "#ffffff",
roleColor: "rgba(255,255,255,0.45)",
font: "'Inter', system-ui, sans-serif",
watermarkColor: "rgba(255,255,255,0.2)",
defs: (_w, _h) => `
<linearGradient id="nebula-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f0c29"/>
<stop offset="50%" stop-color="#302b63"/>
<stop offset="100%" stop-color="#24243e"/>
</linearGradient>
<radialGradient id="nebula-glow1" cx="25%" cy="30%" r="40%">
<stop offset="0%" stop-color="rgba(99,102,241,0.12)"/>
<stop offset="100%" stop-color="transparent"/>
</radialGradient>
<radialGradient id="nebula-glow2" cx="75%" cy="65%" r="35%">
<stop offset="0%" stop-color="rgba(168,85,247,0.08)"/>
<stop offset="100%" stop-color="transparent"/>
</radialGradient>`,
bgExtras: (w, h) => `
<rect width="${w}" height="${h}" fill="url(#nebula-bg)" rx="6"/>
<rect width="${w}" height="${h}" fill="url(#nebula-glow1)"/>
<rect width="${w}" height="${h}" fill="url(#nebula-glow2)"/>`,
renderCard: null,
cardAccent: null,
},
// 03 — Circuit (Linear/Raycast — indigo traces, amethyst CEO)
circuit: {
bgColor: "#0c0c0e",
cardBg: "rgba(99,102,241,0.04)",
cardBorder: "rgba(99,102,241,0.18)",
cardRadius: 5,
cardShadow: null,
lineColor: "rgba(99,102,241,0.35)",
lineWidth: 1.5,
nameColor: "#e4e4e7",
roleColor: "#6366f1",
font: "'Inter', system-ui, sans-serif",
watermarkColor: "rgba(99,102,241,0.3)",
defs: () => "",
bgExtras: () => "",
renderCard: (ln: LayoutNode, theme: StyleTheme) => {
const { tag, roleLabel, emojiSvg } = getRoleInfo(ln.node);
const cx = ln.x + ln.width / 2;
const isCeo = tag === "ceo";
const borderColor = isCeo ? "rgba(168,85,247,0.35)" : theme.cardBorder;
const bgColor = isCeo ? "rgba(168,85,247,0.06)" : theme.cardBg;
const avatarCY = ln.y + 27;
const nameY = ln.y + 66;
const roleY = ln.y + 82;
return `<g>
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${bgColor}" stroke="${borderColor}" stroke-width="1"/>
${renderEmojiAvatar(cx, avatarCY, 17, "rgba(99,102,241,0.08)", emojiSvg, "rgba(99,102,241,0.15)")}
<text x="${cx}" y="${nameY}" text-anchor="middle" font-family="${theme.font}" font-size="13" font-weight="600" fill="${theme.nameColor}" letter-spacing="-0.005em">${escapeXml(ln.node.name)}</text>
<text x="${cx}" y="${roleY}" text-anchor="middle" font-family="${theme.font}" font-size="10" font-weight="500" fill="${theme.roleColor}" letter-spacing="0.07em">${escapeXml(roleLabel).toUpperCase()}</text>
</g>`;
},
cardAccent: null,
},
// 04 — Warmth (Airbnb — light, colored avatars, soft shadows)
warmth: {
bgColor: "#fafaf9",
cardBg: "#ffffff",
cardBorder: "#e7e5e4",
cardRadius: 6,
cardShadow: "rgba(0,0,0,0.05)",
lineColor: "#d6d3d1",
lineWidth: 2,
nameColor: "#1c1917",
roleColor: "#78716c",
font: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
watermarkColor: "rgba(0,0,0,0.25)",
defs: () => "",
bgExtras: () => "",
renderCard: null,
cardAccent: null,
},
// 05 — Schematic (Blueprint — grid bg, monospace, colored top-bars)
schematic: {
bgColor: "#0d1117",
cardBg: "rgba(13,17,23,0.92)",
cardBorder: "#30363d",
cardRadius: 4,
cardShadow: null,
lineColor: "#30363d",
lineWidth: 1.5,
nameColor: "#c9d1d9",
roleColor: "#8b949e",
font: "'JetBrains Mono', 'SF Mono', monospace",
watermarkColor: "rgba(139,148,158,0.3)",
defs: (w, h) => `
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="rgba(48,54,61,0.25)" stroke-width="1"/>
</pattern>`,
bgExtras: (w, h) => `<rect width="${w}" height="${h}" fill="url(#grid)"/>`,
renderCard: (ln: LayoutNode, theme: StyleTheme) => {
const { tag, accentColor, emojiSvg } = getRoleInfo(ln.node);
const cx = ln.x + ln.width / 2;
// Schematic uses monospace role labels
const schemaRoles: Record<string, string> = {
ceo: "chief_executive", cto: "chief_technology", cmo: "chief_marketing",
cfo: "chief_financial", coo: "chief_operating", engineer: "engineer",
quality: "quality_assurance", design: "designer", finance: "finance",
operations: "operations", default: "agent",
};
const roleText = schemaRoles[tag] || schemaRoles.default;
const avatarCY = ln.y + 27;
const nameY = ln.y + 66;
const roleY = ln.y + 82;
return `<g>
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.cardBg}" stroke="${theme.cardBorder}" stroke-width="1"/>
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="2" rx="${theme.cardRadius} ${theme.cardRadius} 0 0" fill="${accentColor}"/>
${renderEmojiAvatar(cx, avatarCY, 17, "rgba(48,54,61,0.3)", emojiSvg, theme.cardBorder)}
<text x="${cx}" y="${nameY}" text-anchor="middle" font-family="${theme.font}" font-size="12" font-weight="600" fill="${theme.nameColor}">${escapeXml(ln.node.name)}</text>
<text x="${cx}" y="${roleY}" text-anchor="middle" font-family="${theme.font}" font-size="10" fill="${theme.roleColor}" letter-spacing="0.02em">${escapeXml(roleText)}</text>
</g>`;
},
cardAccent: null,
},
};
// ── Layout constants ─────────────────────────────────────────────
const CARD_H = 96;
const CARD_MIN_W = 150;
const CARD_PAD_X = 22;
const AVATAR_SIZE = 34;
const GAP_X = 24;
const GAP_Y = 56;
// ── Collapsed avatar grid constants ─────────────────────────────
const MINI_AVATAR_SIZE = 14;
const MINI_AVATAR_GAP = 6;
const MINI_AVATAR_PADDING = 10;
const MINI_AVATAR_MAX_COLS = 8; // max avatars per row in the grid
const PADDING = 48;
const LOGO_PADDING = 16;
// ── Text measurement ─────────────────────────────────────────────
function measureText(text: string, fontSize: number): number {
return text.length * fontSize * 0.58;
}
/** Calculate how many rows the avatar grid needs. */
function avatarGridRows(count: number): number {
return Math.ceil(count / MINI_AVATAR_MAX_COLS);
}
/** Width needed for the avatar grid. */
function avatarGridWidth(count: number): number {
const cols = Math.min(count, MINI_AVATAR_MAX_COLS);
return cols * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP + MINI_AVATAR_PADDING * 2;
}
/** Height of the avatar grid area. */
function avatarGridHeight(count: number): number {
if (count === 0) return 0;
const rows = avatarGridRows(count);
return rows * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP + MINI_AVATAR_PADDING * 2;
}
function cardWidth(node: OrgNode): number {
const { roleLabel: defaultRoleLabel } = getRoleInfo(node);
const roleLabel = node.role.startsWith("×") ? node.role : defaultRoleLabel;
const nameW = measureText(node.name, 14) + CARD_PAD_X * 2;
const roleW = measureText(roleLabel, 11) + CARD_PAD_X * 2;
let w = Math.max(CARD_MIN_W, Math.max(nameW, roleW));
// Widen for avatar grid if needed
if (node.collapsedReports && node.collapsedReports.length > 0) {
w = Math.max(w, avatarGridWidth(node.collapsedReports.length));
}
return w;
}
function cardHeight(node: OrgNode): number {
if (node.collapsedReports && node.collapsedReports.length > 0) {
return CARD_H + avatarGridHeight(node.collapsedReports.length);
}
return CARD_H;
}
// ── Tree layout (top-down, centered) ─────────────────────────────
function subtreeWidth(node: OrgNode): number {
const cw = cardWidth(node);
if (!node.reports || node.reports.length === 0) return cw;
const childrenW = node.reports.reduce(
(sum, child, i) => sum + subtreeWidth(child) + (i > 0 ? GAP_X : 0),
0,
);
return Math.max(cw, childrenW);
}
function layoutTree(node: OrgNode, x: number, y: number): LayoutNode {
const w = cardWidth(node);
const sw = subtreeWidth(node);
const cardX = x + (sw - w) / 2;
const h = cardHeight(node);
const layoutNode: LayoutNode = {
node,
x: cardX,
y,
width: w,
height: h,
children: [],
};
if (node.reports && node.reports.length > 0) {
let childX = x;
const childY = y + h + GAP_Y;
for (let i = 0; i < node.reports.length; i++) {
const child = node.reports[i];
const childSW = subtreeWidth(child);
layoutNode.children.push(layoutTree(child, childX, childY));
childX += childSW + GAP_X;
}
}
return layoutNode;
}
// ── SVG rendering ────────────────────────────────────────────────
function escapeXml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/** Render a colorful Twemoji inside a circle at (cx, cy) with given radius */
function renderEmojiAvatar(cx: number, cy: number, radius: number, bgFill: string, emojiSvg: string, bgStroke?: string): string {
const emojiSize = radius * 1.3; // emoji fills most of the circle
const emojiX = cx - emojiSize / 2;
const emojiY = cy - emojiSize / 2;
const stroke = bgStroke ? `stroke="${bgStroke}" stroke-width="1"` : "";
return `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${bgFill}" ${stroke}/>
<svg x="${emojiX}" y="${emojiY}" width="${emojiSize}" height="${emojiSize}" viewBox="0 0 36 36">${emojiSvg}</svg>`;
}
function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string {
// Overflow placeholder card: just shows "+N more" text, no avatar
if (ln.node.role === "overflow") {
const cx = ln.x + ln.width / 2;
const cy = ln.y + ln.height / 2;
return `<g>
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.bgColor}" stroke="${theme.cardBorder}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="${cx}" y="${cy + 5}" text-anchor="middle" font-family="${theme.font}" font-size="13" font-weight="600" fill="${theme.roleColor}">${escapeXml(ln.node.name)}</text>
</g>`;
}
const { roleLabel: defaultRoleLabel, bg, emojiSvg } = getRoleInfo(ln.node);
// Use node.role directly when it's a collapse badge (e.g. "×15 reports")
const roleLabel = ln.node.role.startsWith("×") ? ln.node.role : defaultRoleLabel;
const cx = ln.x + ln.width / 2;
const avatarCY = ln.y + 27;
const nameY = ln.y + 66;
const roleY = ln.y + 82;
const filterId = `shadow-${ln.node.id}`;
const shadowFilter = theme.cardShadow
? `filter="url(#${filterId})"`
: "";
const shadowDef = theme.cardShadow
? `<filter id="${filterId}" x="-4" y="-2" width="${ln.width + 8}" height="${ln.height + 6}">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="${theme.cardShadow}"/>
<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="rgba(0,0,0,0.03)"/>
</filter>`
: "";
// For dark themes without avatars, use a subtle circle
const isLight = theme.bgColor === "#fafaf9" || theme.bgColor === "#ffffff";
const avatarBg = isLight ? bg : "rgba(255,255,255,0.06)";
const avatarStroke = isLight ? undefined : "rgba(255,255,255,0.08)";
// Render collapsed avatar grid if this node has hidden reports
let avatarGridSvg = "";
const collapsed = ln.node.collapsedReports;
if (collapsed && collapsed.length > 0) {
const gridTop = ln.y + CARD_H + MINI_AVATAR_PADDING;
const cols = Math.min(collapsed.length, MINI_AVATAR_MAX_COLS);
const gridTotalW = cols * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP;
const gridStartX = ln.x + (ln.width - gridTotalW) / 2;
for (let i = 0; i < collapsed.length; i++) {
const col = i % MINI_AVATAR_MAX_COLS;
const row = Math.floor(i / MINI_AVATAR_MAX_COLS);
const dotCx = gridStartX + col * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) + MINI_AVATAR_SIZE / 2;
const dotCy = gridTop + row * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) + MINI_AVATAR_SIZE / 2;
const { bg: dotBg } = getRoleInfo(collapsed[i]);
const dotFill = isLight ? dotBg : "rgba(255,255,255,0.1)";
avatarGridSvg += `<circle cx="${dotCx}" cy="${dotCy}" r="${MINI_AVATAR_SIZE / 2}" fill="${dotFill}" stroke="${theme.cardBorder}" stroke-width="0.5"/>`;
}
}
return `<g>
${shadowDef}
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.cardBg}" stroke="${theme.cardBorder}" stroke-width="1" ${shadowFilter}/>
${renderEmojiAvatar(cx, avatarCY, AVATAR_SIZE / 2, avatarBg, emojiSvg, avatarStroke)}
<text x="${cx}" y="${nameY}" text-anchor="middle" font-family="${theme.font}" font-size="14" font-weight="600" fill="${theme.nameColor}">${escapeXml(ln.node.name)}</text>
<text x="${cx}" y="${roleY}" text-anchor="middle" font-family="${theme.font}" font-size="11" font-weight="500" fill="${theme.roleColor}">${escapeXml(roleLabel)}</text>
${avatarGridSvg}
</g>`;
}
function renderConnectors(ln: LayoutNode, theme: StyleTheme): string {
if (ln.children.length === 0) return "";
const parentCx = ln.x + ln.width / 2;
const parentBottom = ln.y + ln.height;
const midY = parentBottom + GAP_Y / 2;
const lc = theme.lineColor;
const lw = theme.lineWidth;
let svg = "";
svg += `<line x1="${parentCx}" y1="${parentBottom}" x2="${parentCx}" y2="${midY}" stroke="${lc}" stroke-width="${lw}"/>`;
if (ln.children.length === 1) {
const childCx = ln.children[0].x + ln.children[0].width / 2;
svg += `<line x1="${childCx}" y1="${midY}" x2="${childCx}" y2="${ln.children[0].y}" stroke="${lc}" stroke-width="${lw}"/>`;
} else {
const leftCx = ln.children[0].x + ln.children[0].width / 2;
const rightCx = ln.children[ln.children.length - 1].x + ln.children[ln.children.length - 1].width / 2;
svg += `<line x1="${leftCx}" y1="${midY}" x2="${rightCx}" y2="${midY}" stroke="${lc}" stroke-width="${lw}"/>`;
for (const child of ln.children) {
const childCx = child.x + child.width / 2;
svg += `<line x1="${childCx}" y1="${midY}" x2="${childCx}" y2="${child.y}" stroke="${lc}" stroke-width="${lw}"/>`;
}
}
for (const child of ln.children) {
svg += renderConnectors(child, theme);
}
return svg;
}
function renderCards(ln: LayoutNode, theme: StyleTheme): string {
const render = theme.renderCard || defaultRenderCard;
let svg = render(ln, theme);
for (const child of ln.children) {
svg += renderCards(child, theme);
}
return svg;
}
function treeBounds(ln: LayoutNode): { minX: number; minY: number; maxX: number; maxY: number } {
let minX = ln.x;
let minY = ln.y;
let maxX = ln.x + ln.width;
let maxY = ln.y + ln.height;
for (const child of ln.children) {
const cb = treeBounds(child);
minX = Math.min(minX, cb.minX);
minY = Math.min(minY, cb.minY);
maxX = Math.max(maxX, cb.maxX);
maxY = Math.max(maxY, cb.maxY);
}
return { minX, minY, maxX, maxY };
}
// Paperclip logo: scaled icon (~16px) + wordmark (13px), vertically centered
const PAPERCLIP_LOGO_SVG = `<g>
<g transform="scale(0.72)" transform-origin="0 0">
<path stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none" d="m18 4-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</g>
<text x="22" y="11.5" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="currentColor">Paperclip</text>
</g>`;
// ── Public API ───────────────────────────────────────────────────
// GitHub recommended social media preview dimensions
const TARGET_W = 1280;
const TARGET_H = 640;
export interface OrgChartOverlay {
/** Company name displayed top-left */
companyName?: string;
/** Summary stats displayed bottom-right, e.g. "Agents: 5, Skills: 8" */
stats?: string;
}
/** Count total nodes in a tree. */
function countNodes(nodes: OrgNode[]): number {
let count = 0;
for (const n of nodes) {
count += 1 + countNodes(n.reports ?? []);
}
return count;
}
/** Threshold: auto-collapse orgs larger than this. */
const COLLAPSE_THRESHOLD = 20;
/** Max cards that can fit across the 1280px image. */
const MAX_LEVEL_WIDTH = 8;
/** Max children shown per parent before truncation with "and N more". */
const MAX_CHILDREN_SHOWN = 6;
/** Flatten all descendants of a node into a single list. */
function flattenDescendants(nodes: OrgNode[]): OrgNode[] {
const result: OrgNode[] = [];
for (const n of nodes) {
result.push(n);
result.push(...flattenDescendants(n.reports ?? []));
}
return result;
}
/** Collect all nodes at a given depth in the tree. */
function nodesAtDepth(nodes: OrgNode[], depth: number): OrgNode[] {
if (depth === 0) return nodes;
const result: OrgNode[] = [];
for (const n of nodes) {
result.push(...nodesAtDepth(n.reports ?? [], depth - 1));
}
return result;
}
/**
* Estimate how many cards would be shown at the next level if we expand,
* considering truncation (each parent shows at most MAX_CHILDREN_SHOWN + 1 placeholder).
*/
function estimateNextLevelWidth(parentNodes: OrgNode[]): number {
let total = 0;
for (const p of parentNodes) {
const childCount = (p.reports ?? []).length;
if (childCount === 0) continue;
total += Math.min(childCount, MAX_CHILDREN_SHOWN + 1); // +1 for "and N more" placeholder
}
return total;
}
/**
* Collapse a node's children to avatar dots (for wide levels that can't expand).
*/
function collapseToAvatars(node: OrgNode): OrgNode {
const childCount = countNodes(node.reports ?? []);
if (childCount === 0) return node;
return {
...node,
role: `×${childCount} reports`,
collapsedReports: flattenDescendants(node.reports ?? []),
reports: [],
};
}
/**
* Truncate a node's children: keep first MAX_CHILDREN_SHOWN, replace rest with
* a summary "and N more" placeholder node (rendered as a count card).
*/
function truncateChildren(node: OrgNode): OrgNode {
const children = node.reports ?? [];
if (children.length <= MAX_CHILDREN_SHOWN) return node;
const kept = children.slice(0, MAX_CHILDREN_SHOWN);
const hiddenCount = children.length - MAX_CHILDREN_SHOWN;
const placeholder: OrgNode = {
id: `${node.id}-more`,
name: `+${hiddenCount} more`,
role: "overflow",
status: "active",
reports: [],
};
return { ...node, reports: [...kept, placeholder] };
}
/**
* Adaptive collapse: expands levels as long as they fit, truncates or collapses
* when a level is too wide.
*/
function smartCollapseTree(roots: OrgNode[]): OrgNode[] {
// Deep clone so we can mutate
const clone = (nodes: OrgNode[]): OrgNode[] =>
nodes.map((n) => ({ ...n, reports: clone(n.reports ?? []) }));
const tree = clone(roots);
// Walk levels from root down
for (let depth = 0; depth < 10; depth++) {
const parents = nodesAtDepth(tree, depth);
const parentsWithChildren = parents.filter((p) => (p.reports ?? []).length > 0);
if (parentsWithChildren.length === 0) break;
const nextWidth = estimateNextLevelWidth(parentsWithChildren);
if (nextWidth <= MAX_LEVEL_WIDTH) {
// Next level fits with truncation — truncate oversized parents, then continue deeper
for (const p of parentsWithChildren) {
if ((p.reports ?? []).length > MAX_CHILDREN_SHOWN) {
const truncated = truncateChildren(p);
p.reports = truncated.reports;
}
}
continue;
}
// Next level is too wide — collapse all children at this level to avatars
for (const p of parentsWithChildren) {
const collapsed = collapseToAvatars(p);
p.role = collapsed.role;
p.collapsedReports = collapsed.collapsedReports;
p.reports = [];
}
break;
}
return tree;
}
export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): string {
const theme = THEMES[style] || THEMES.warmth;
// Auto-collapse large orgs to keep the chart readable
const totalNodes = countNodes(orgTree);
const effectiveTree = totalNodes > COLLAPSE_THRESHOLD ? smartCollapseTree(orgTree) : orgTree;
let root: OrgNode;
if (effectiveTree.length === 1) {
root = effectiveTree[0];
} else {
root = {
id: "virtual-root",
name: "Organization",
role: "Root",
status: "active",
reports: effectiveTree,
};
}
const layout = layoutTree(root, PADDING, PADDING + 24);
const bounds = treeBounds(layout);
const contentW = bounds.maxX + PADDING;
const contentH = bounds.maxY + PADDING;
// Scale content to fit within the fixed target dimensions
const scale = Math.min(TARGET_W / contentW, TARGET_H / contentH, 1);
const scaledW = contentW * scale;
const scaledH = contentH * scale;
// Center the scaled content within the target frame
const offsetX = (TARGET_W - scaledW) / 2;
const offsetY = (TARGET_H - scaledH) / 2;
const logoX = TARGET_W - 110 - LOGO_PADDING;
const logoY = LOGO_PADDING;
// Optional overlay elements
const overlayNameSvg = overlay?.companyName
? `<text x="${LOGO_PADDING}" y="${LOGO_PADDING + 16}" font-family="'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font-size="22" font-weight="700" fill="${theme.nameColor}">${svgEscape(overlay.companyName)}</text>`
: "";
const overlayStatsSvg = overlay?.stats
? `<text x="${TARGET_W - LOGO_PADDING}" y="${TARGET_H - LOGO_PADDING}" text-anchor="end" font-family="'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="500" fill="${theme.roleColor}">${svgEscape(overlay.stats)}</text>`
: "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${TARGET_W}" height="${TARGET_H}" viewBox="0 0 ${TARGET_W} ${TARGET_H}">
<defs>${theme.defs(TARGET_W, TARGET_H)}</defs>
<rect width="100%" height="100%" fill="${theme.bgColor}" rx="6"/>
${theme.bgExtras(TARGET_W, TARGET_H)}
<g transform="translate(${logoX}, ${logoY})" color="${theme.watermarkColor}">
${PAPERCLIP_LOGO_SVG}
</g>
${overlayNameSvg}
${overlayStatsSvg}
<g transform="translate(${offsetX}, ${offsetY}) scale(${scale})">
${renderConnectors(layout, theme)}
${renderCards(layout, theme)}
</g>
</svg>`;
}
function svgEscape(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): Promise<Buffer> {
const svg = renderOrgChartSvg(orgTree, style, overlay);
const sharpModule = await import("sharp");
const sharp = sharpModule.default;
// Render at 2x density for retina quality, resize to exact target dimensions
return sharp(Buffer.from(svg), { density: 144 })
.resize(TARGET_W, TARGET_H)
.png()
.toBuffer();
}

View file

@ -0,0 +1,299 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
createRoutineSchema,
createRoutineTriggerSchema,
rotateRoutineTriggerSecretSchema,
runRoutineSchema,
updateRoutineSchema,
updateRoutineTriggerSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { accessService, logActivity, routineService } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { forbidden, unauthorized } from "../errors.js";
export function routineRoutes(db: Db) {
const router = Router();
const svc = routineService(db);
const access = accessService(db);
async function assertBoardCanAssignTasks(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") return;
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
if (!allowed) {
throw forbidden("Missing permission: tasks:assign");
}
}
function assertCanManageCompanyRoutine(req: Request, companyId: string, assigneeAgentId?: string | null) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return;
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
if (assigneeAgentId && assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only manage routines assigned to themselves");
}
}
async function assertCanManageExistingRoutine(req: Request, routineId: string) {
const routine = await svc.get(routineId);
if (!routine) return null;
assertCompanyAccess(req, routine.companyId);
if (req.actor.type === "board") return routine;
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
if (routine.assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only manage routines assigned to themselves");
}
return routine;
}
router.get("/companies/:companyId/routines", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result);
});
router.post("/companies/:companyId/routines", validate(createRoutineSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertBoardCanAssignTasks(req, companyId);
assertCanManageCompanyRoutine(req, companyId, req.body.assigneeAgentId);
const created = await svc.create(companyId, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.created",
entityType: "routine",
entityId: created.id,
details: { title: created.title, assigneeAgentId: created.assigneeAgentId },
});
res.status(201).json(created);
});
router.get("/routines/:id", async (req, res) => {
const detail = await svc.getDetail(req.params.id as string);
if (!detail) {
res.status(404).json({ error: "Routine not found" });
return;
}
assertCompanyAccess(req, detail.companyId);
res.json(detail);
});
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const assigneeWillChange =
req.body.assigneeAgentId !== undefined &&
req.body.assigneeAgentId !== routine.assigneeAgentId;
if (assigneeWillChange) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
const statusWillActivate =
req.body.status !== undefined &&
req.body.status === "active" &&
routine.status !== "active";
if (statusWillActivate) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only assign routines to themselves");
}
const updated = await svc.update(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.updated",
entityType: "routine",
entityId: routine.id,
details: { title: updated?.title ?? routine.title },
});
res.json(updated);
});
router.get("/routines/:id/runs", async (req, res) => {
const routine = await svc.get(req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
assertCompanyAccess(req, routine.companyId);
const limit = Number(req.query.limit ?? 50);
const result = await svc.listRuns(routine.id, Number.isFinite(limit) ? limit : 50);
res.json(result);
});
router.post("/routines/:id/triggers", validate(createRoutineTriggerSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const created = await svc.createTrigger(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_created",
entityType: "routine_trigger",
entityId: created.trigger.id,
details: { routineId: routine.id, kind: created.trigger.kind },
});
res.status(201).json(created);
});
router.patch("/routine-triggers/:id", validate(updateRoutineTriggerSchema), async (req, res) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const updated = await svc.updateTrigger(trigger.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_updated",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id, kind: updated?.kind ?? trigger.kind },
});
res.json(updated);
});
router.delete("/routine-triggers/:id", async (req, res) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await svc.deleteTrigger(trigger.id);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_deleted",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id, kind: trigger.kind },
});
res.status(204).end();
});
router.post(
"/routine-triggers/:id/rotate-secret",
validate(rotateRoutineTriggerSecretSchema),
async (req, res) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const rotated = await svc.rotateTriggerSecret(trigger.id, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_secret_rotated",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id },
});
res.json(rotated);
},
);
router.post("/routines/:id/run", validate(runRoutineSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const run = await svc.runRoutine(routine.id, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.run_triggered",
entityType: "routine_run",
entityId: run.id,
details: { routineId: routine.id, source: run.source, status: run.status },
});
res.status(202).json(run);
});
router.post("/routine-triggers/public/:publicId/fire", async (req, res) => {
const result = await svc.firePublicTrigger(req.params.publicId as string, {
authorizationHeader: req.header("authorization"),
signatureHeader: req.header("x-paperclip-signature"),
timestampHeader: req.header("x-paperclip-timestamp"),
idempotencyKey: req.header("idempotency-key"),
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,
payload: typeof req.body === "object" && req.body !== null ? req.body as Record<string, unknown> : null,
});
res.status(202).json(result);
});
return router;
}

View file

@ -83,6 +83,20 @@ export function accessService(db: Db) {
.orderBy(sql`${companyMemberships.createdAt} desc`);
}
async function listActiveUserMemberships(companyId: string) {
return db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
),
)
.orderBy(sql`${companyMemberships.createdAt} asc`);
}
async function setMemberPermissions(
companyId: string,
memberId: string,
@ -251,6 +265,20 @@ export function accessService(db: Db) {
});
}
async function copyActiveUserMemberships(sourceCompanyId: string, targetCompanyId: string) {
const sourceMemberships = await listActiveUserMemberships(sourceCompanyId);
for (const membership of sourceMemberships) {
await ensureMembership(
targetCompanyId,
"user",
membership.principalId,
membership.membershipRole,
"active",
);
}
return sourceMemberships;
}
async function listPrincipalGrants(
companyId: string,
principalType: PrincipalType,
@ -338,6 +366,8 @@ export function accessService(db: Db) {
getMembership,
ensureMembership,
listMembers,
listActiveUserMemberships,
copyActiveUserMemberships,
setMemberPermissions,
promoteInstanceAdmin,
demoteInstanceAdmin,

View file

@ -8,6 +8,7 @@ import { redactCurrentUserValue } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
import { logger } from "../middleware/logger.js";
import type { PluginEventBus } from "./plugin-event-bus.js";
import { instanceSettingsService } from "./instance-settings.js";
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
@ -34,8 +35,13 @@ export interface LogActivityInput {
}
export async function logActivity(db: Db, input: LogActivityInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
};
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
const redactedDetails = sanitizedDetails
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
: null;
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,

View file

@ -0,0 +1,734 @@
import fs from "node:fs/promises";
import path from "node:path";
import { notFound, unprocessable } from "../errors.js";
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
const ENTRY_FILE_DEFAULT = "AGENTS.md";
const MODE_KEY = "instructionsBundleMode";
const ROOT_KEY = "instructionsRootPath";
const ENTRY_KEY = "instructionsEntryFile";
const FILE_KEY = "instructionsFilePath";
const PROMPT_KEY = "promptTemplate";
const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate";
const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md";
const IGNORED_INSTRUCTIONS_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "Desktop.ini"]);
const IGNORED_INSTRUCTIONS_DIRECTORY_NAMES = new Set([
".git",
".nox",
".pytest_cache",
".ruff_cache",
".tox",
".venv",
"__pycache__",
"node_modules",
"venv",
]);
type BundleMode = "managed" | "external";
type AgentLike = {
id: string;
companyId: string;
name: string;
adapterConfig: unknown;
};
type AgentInstructionsFileSummary = {
path: string;
size: number;
language: string;
markdown: boolean;
isEntryFile: boolean;
editable: boolean;
deprecated: boolean;
virtual: boolean;
};
type AgentInstructionsFileDetail = AgentInstructionsFileSummary & {
content: string;
editable: boolean;
};
type AgentInstructionsBundle = {
agentId: string;
companyId: string;
mode: BundleMode | null;
rootPath: string | null;
managedRootPath: string;
entryFile: string;
resolvedEntryPath: string | null;
editable: boolean;
warnings: string[];
legacyPromptTemplateActive: boolean;
legacyBootstrapPromptTemplateActive: boolean;
files: AgentInstructionsFileSummary[];
};
type BundleState = {
config: Record<string, unknown>;
mode: BundleMode | null;
rootPath: string | null;
entryFile: string;
resolvedEntryPath: string | null;
warnings: string[];
legacyPromptTemplateActive: boolean;
legacyBootstrapPromptTemplateActive: boolean;
};
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function isBundleMode(value: unknown): value is BundleMode {
return value === "managed" || value === "external";
}
function inferLanguage(relativePath: string): string {
const lower = relativePath.toLowerCase();
if (lower.endsWith(".md")) return "markdown";
if (lower.endsWith(".json")) return "json";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
if (lower.endsWith(".js") || lower.endsWith(".jsx") || lower.endsWith(".mjs") || lower.endsWith(".cjs")) {
return "javascript";
}
if (lower.endsWith(".sh")) return "bash";
if (lower.endsWith(".py")) return "python";
if (lower.endsWith(".toml")) return "toml";
if (lower.endsWith(".txt")) return "text";
return "text";
}
function isMarkdown(relativePath: string) {
return relativePath.toLowerCase().endsWith(".md");
}
function normalizeRelativeFilePath(candidatePath: string): string {
const normalized = path.posix.normalize(candidatePath.replaceAll("\\", "/")).replace(/^\/+/, "");
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
throw unprocessable("Instructions file path must stay within the bundle root");
}
return normalized;
}
function resolvePathWithinRoot(rootPath: string, relativePath: string): string {
const normalizedRelativePath = normalizeRelativeFilePath(relativePath);
const absoluteRoot = path.resolve(rootPath);
const absolutePath = path.resolve(absoluteRoot, normalizedRelativePath);
const relativeToRoot = path.relative(absoluteRoot, absolutePath);
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`)) {
throw unprocessable("Instructions file path must stay within the bundle root");
}
return absolutePath;
}
function resolveManagedInstructionsRoot(agent: AgentLike): string {
return path.resolve(
resolvePaperclipInstanceRoot(),
"companies",
agent.companyId,
"agents",
agent.id,
"instructions",
);
}
function resolveLegacyInstructionsPath(candidatePath: string, config: Record<string, unknown>): string {
if (path.isAbsolute(candidatePath)) return candidatePath;
const cwd = asString(config.cwd);
if (!cwd || !path.isAbsolute(cwd)) {
throw unprocessable(
"Legacy relative instructionsFilePath requires adapterConfig.cwd to be set to an absolute path",
);
}
return path.resolve(cwd, candidatePath);
}
async function statIfExists(targetPath: string) {
return fs.stat(targetPath).catch(() => null);
}
function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boolean; isFile(): boolean }) {
if (entry.name === "." || entry.name === "..") return true;
if (entry.isDirectory()) {
return IGNORED_INSTRUCTIONS_DIRECTORY_NAMES.has(entry.name);
}
if (!entry.isFile()) return false;
return (
IGNORED_INSTRUCTIONS_FILE_NAMES.has(entry.name)
|| entry.name.startsWith("._")
|| entry.name.endsWith(".pyc")
|| entry.name.endsWith(".pyo")
);
}
async function listFilesRecursive(rootPath: string): Promise<string[]> {
const output: string[] = [];
async function walk(currentPath: string, relativeDir: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (shouldIgnoreInstructionsEntry(entry)) continue;
const absolutePath = path.join(currentPath, entry.name);
const relativePath = normalizeRelativeFilePath(
relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name,
);
if (entry.isDirectory()) {
await walk(absolutePath, relativePath);
continue;
}
if (!entry.isFile()) continue;
output.push(relativePath);
}
}
await walk(rootPath, "");
return output.sort((left, right) => left.localeCompare(right));
}
async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise<AgentInstructionsFileSummary> {
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
const stat = await fs.stat(absolutePath);
return {
path: relativePath,
size: stat.size,
language: inferLanguage(relativePath),
markdown: isMarkdown(relativePath),
isEntryFile: relativePath === entryFile,
editable: true,
deprecated: false,
virtual: false,
};
}
async function readLegacyInstructions(agent: AgentLike, config: Record<string, unknown>): Promise<string> {
const instructionsFilePath = asString(config[FILE_KEY]);
if (instructionsFilePath) {
try {
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, config);
return await fs.readFile(resolvedPath, "utf8");
} catch {
// Fall back to promptTemplate below.
}
}
return asString(config[PROMPT_KEY]) ?? "";
}
function deriveBundleState(agent: AgentLike): BundleState {
const config = asRecord(agent.adapterConfig);
const warnings: string[] = [];
const storedModeRaw = config[MODE_KEY];
const storedRootRaw = asString(config[ROOT_KEY]);
const legacyInstructionsPath = asString(config[FILE_KEY]);
let mode: BundleMode | null = isBundleMode(storedModeRaw) ? storedModeRaw : null;
let rootPath = storedRootRaw ? resolveHomeAwarePath(storedRootRaw) : null;
let entryFile = ENTRY_FILE_DEFAULT;
const storedEntryRaw = asString(config[ENTRY_KEY]);
if (storedEntryRaw) {
try {
entryFile = normalizeRelativeFilePath(storedEntryRaw);
} catch {
warnings.push(`Ignored invalid instructions entry file "${storedEntryRaw}".`);
}
}
if (!rootPath && legacyInstructionsPath) {
try {
const resolvedLegacyPath = resolveLegacyInstructionsPath(legacyInstructionsPath, config);
rootPath = path.dirname(resolvedLegacyPath);
entryFile = path.basename(resolvedLegacyPath);
mode = resolvedLegacyPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`)
|| resolvedLegacyPath === path.join(resolveManagedInstructionsRoot(agent), entryFile)
? "managed"
: "external";
if (!path.isAbsolute(legacyInstructionsPath)) {
warnings.push("Using legacy relative instructionsFilePath; migrate this agent to a managed or absolute external bundle.");
}
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
}
}
const resolvedEntryPath = rootPath ? path.resolve(rootPath, entryFile) : null;
return {
config,
mode,
rootPath,
entryFile,
resolvedEntryPath,
warnings,
legacyPromptTemplateActive: Boolean(asString(config[PROMPT_KEY])),
legacyBootstrapPromptTemplateActive: Boolean(asString(config[BOOTSTRAP_PROMPT_KEY])),
};
}
async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> {
const managedRootPath = resolveManagedInstructionsRoot(agent);
const stat = await statIfExists(managedRootPath);
if (!stat?.isDirectory()) return state;
const files = await listFilesRecursive(managedRootPath);
if (files.length === 0) return state;
const recoveredEntryFile = files.includes(state.entryFile)
? state.entryFile
: files.includes(ENTRY_FILE_DEFAULT)
? ENTRY_FILE_DEFAULT
: files[0]!;
if (!state.rootPath) {
return {
...state,
mode: "managed",
rootPath: managedRootPath,
entryFile: recoveredEntryFile,
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
};
}
if (state.mode === "external") return state;
const resolvedConfiguredRoot = path.resolve(state.rootPath);
const configuredRootMatchesManaged = resolvedConfiguredRoot === managedRootPath;
const hasEntryMismatch = recoveredEntryFile !== state.entryFile;
if (configuredRootMatchesManaged && !hasEntryMismatch) {
return state;
}
const warnings = [...state.warnings];
if (!configuredRootMatchesManaged) {
warnings.push(
`Recovered managed instructions from disk at ${managedRootPath}; ignoring stale configured root ${state.rootPath}.`,
);
}
if (hasEntryMismatch) {
warnings.push(
`Recovered managed instructions entry file from disk as ${recoveredEntryFile}; previous entry ${state.entryFile} was missing.`,
);
}
return {
...state,
mode: "managed",
rootPath: managedRootPath,
entryFile: recoveredEntryFile,
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
warnings,
};
}
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
const nextFiles = [...files];
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
const legacyPromptTemplate = asString(state.config[PROMPT_KEY]) ?? "";
nextFiles.push({
path: LEGACY_PROMPT_TEMPLATE_PATH,
size: legacyPromptTemplate.length,
language: "markdown",
markdown: true,
isEntryFile: false,
editable: true,
deprecated: true,
virtual: true,
});
}
nextFiles.sort((left, right) => left.path.localeCompare(right.path));
return {
agentId: agent.id,
companyId: agent.companyId,
mode: state.mode,
rootPath: state.rootPath,
managedRootPath: resolveManagedInstructionsRoot(agent),
entryFile: state.entryFile,
resolvedEntryPath: state.resolvedEntryPath,
editable: Boolean(state.rootPath),
warnings: state.warnings,
legacyPromptTemplateActive: state.legacyPromptTemplateActive,
legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive,
files: nextFiles,
};
}
function applyBundleConfig(
config: Record<string, unknown>,
input: {
mode: BundleMode;
rootPath: string;
entryFile: string;
clearLegacyPromptTemplate?: boolean;
},
): Record<string, unknown> {
const next: Record<string, unknown> = {
...config,
[MODE_KEY]: input.mode,
[ROOT_KEY]: input.rootPath,
[ENTRY_KEY]: input.entryFile,
[FILE_KEY]: path.resolve(input.rootPath, input.entryFile),
};
if (input.clearLegacyPromptTemplate) {
delete next[PROMPT_KEY];
delete next[BOOTSTRAP_PROMPT_KEY];
}
return next;
}
function buildPersistedBundleConfig(
derived: BundleState,
current: BundleState,
options?: { clearLegacyPromptTemplate?: boolean },
): Record<string, unknown> {
const currentRootPath = current.rootPath ? path.resolve(current.rootPath) : null;
const derivedRootPath = derived.rootPath ? path.resolve(derived.rootPath) : null;
const configMatchesRecoveredState =
derived.mode === current.mode
&& derivedRootPath !== null
&& currentRootPath !== null
&& derivedRootPath === currentRootPath
&& derived.entryFile === current.entryFile;
if (configMatchesRecoveredState && !options?.clearLegacyPromptTemplate) {
return current.config;
}
if (!current.rootPath || !current.mode) {
return current.config;
}
return applyBundleConfig(current.config, {
mode: current.mode,
rootPath: current.rootPath,
entryFile: current.entryFile,
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
});
}
async function writeBundleFiles(
rootPath: string,
files: Record<string, string>,
options?: { overwriteExisting?: boolean },
) {
for (const [relativePath, content] of Object.entries(files)) {
const normalizedPath = normalizeRelativeFilePath(relativePath);
const absolutePath = resolvePathWithinRoot(rootPath, normalizedPath);
const existingStat = await statIfExists(absolutePath);
if (existingStat?.isFile() && !options?.overwriteExisting) continue;
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8");
}
}
export function syncInstructionsBundleConfigFromFilePath(
agent: AgentLike,
adapterConfig: Record<string, unknown>,
): Record<string, unknown> {
const instructionsFilePath = asString(adapterConfig[FILE_KEY]);
const next = { ...adapterConfig };
if (!instructionsFilePath) {
delete next[MODE_KEY];
delete next[ROOT_KEY];
delete next[ENTRY_KEY];
return next;
}
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, adapterConfig);
const rootPath = path.dirname(resolvedPath);
const entryFile = path.basename(resolvedPath);
const mode: BundleMode = resolvedPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`)
|| resolvedPath === path.join(resolveManagedInstructionsRoot(agent), entryFile)
? "managed"
: "external";
return applyBundleConfig(next, { mode, rootPath, entryFile });
}
export function agentInstructionsService() {
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (!state.rootPath) return toBundle(agent, state, []);
const stat = await statIfExists(state.rootPath);
if (!stat?.isDirectory()) {
return toBundle(agent, {
...state,
warnings: [...state.warnings, `Instructions root does not exist: ${state.rootPath}`],
}, []);
}
const files = await listFilesRecursive(state.rootPath);
const summaries = await Promise.all(files.map((relativePath) => readFileSummary(state.rootPath!, relativePath, state.entryFile)));
return toBundle(agent, state, summaries);
}
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const content = asString(state.config[PROMPT_KEY]);
if (content === null) throw notFound("Instructions file not found");
return {
path: LEGACY_PROMPT_TEMPLATE_PATH,
size: content.length,
language: "markdown",
markdown: true,
isEntryFile: false,
editable: true,
deprecated: true,
virtual: true,
content,
};
}
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath);
const [content, stat] = await Promise.all([
fs.readFile(absolutePath, "utf8").catch(() => null),
fs.stat(absolutePath).catch(() => null),
]);
if (content === null || !stat?.isFile()) throw notFound("Instructions file not found");
const normalizedPath = normalizeRelativeFilePath(relativePath);
return {
path: normalizedPath,
size: stat.size,
language: inferLanguage(normalizedPath),
markdown: isMarkdown(normalizedPath),
isEntryFile: normalizedPath === state.entryFile,
editable: true,
deprecated: false,
virtual: false,
content,
};
}
async function ensureWritableBundle(
agent: AgentLike,
options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
const derived = deriveBundleState(agent);
const current = await recoverManagedBundleState(agent, derived);
if (current.rootPath && current.mode) {
const adapterConfig = buildPersistedBundleConfig(derived, current, options);
return {
adapterConfig,
state: deriveBundleState({ ...agent, adapterConfig }),
};
}
const managedRoot = resolveManagedInstructionsRoot(agent);
const entryFile = current.entryFile || ENTRY_FILE_DEFAULT;
const nextConfig = applyBundleConfig(current.config, {
mode: "managed",
rootPath: managedRoot,
entryFile,
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
});
await fs.mkdir(managedRoot, { recursive: true });
const entryPath = resolvePathWithinRoot(managedRoot, entryFile);
const entryStat = await statIfExists(entryPath);
if (!entryStat?.isFile()) {
const legacyInstructions = await readLegacyInstructions(agent, current.config);
if (legacyInstructions.trim().length > 0) {
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(entryPath, legacyInstructions, "utf8");
}
}
return {
adapterConfig: nextConfig,
state: deriveBundleState({ ...agent, adapterConfig: nextConfig }),
};
}
async function updateBundle(
agent: AgentLike,
input: {
mode?: BundleMode;
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
const nextMode = input.mode ?? state.mode ?? "managed";
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
let nextRootPath: string;
if (nextMode === "managed") {
nextRootPath = resolveManagedInstructionsRoot(agent);
} else {
const rootPath = asString(input.rootPath) ?? state.rootPath;
if (!rootPath) {
throw unprocessable("External instructions bundles require an absolute rootPath");
}
const resolvedRoot = resolveHomeAwarePath(rootPath);
if (!path.isAbsolute(resolvedRoot)) {
throw unprocessable("External instructions bundles require an absolute rootPath");
}
nextRootPath = resolvedRoot;
}
await fs.mkdir(nextRootPath, { recursive: true });
const existingFiles = await listFilesRecursive(nextRootPath);
const exported = await exportFiles(agent);
if (existingFiles.length === 0) {
await writeBundleFiles(nextRootPath, exported.files);
}
const refreshedFiles = existingFiles.length === 0 ? await listFilesRecursive(nextRootPath) : existingFiles;
if (!refreshedFiles.includes(nextEntryFile)) {
const nextEntryContent = exported.files[nextEntryFile] ?? exported.files[exported.entryFile] ?? "";
await writeBundleFiles(nextRootPath, { [nextEntryFile]: nextEntryContent });
}
const nextConfig = applyBundleConfig(state.config, {
mode: nextMode,
rootPath: nextRootPath,
entryFile: nextEntryFile,
clearLegacyPromptTemplate: input.clearLegacyPromptTemplate,
});
const nextBundle = await getBundle({ ...agent, adapterConfig: nextConfig });
return { bundle: nextBundle, adapterConfig: nextConfig };
}
async function writeFile(
agent: AgentLike,
relativePath: string,
content: string,
options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{
bundle: AgentInstructionsBundle;
file: AgentInstructionsFileDetail;
adapterConfig: Record<string, unknown>;
}> {
const current = deriveBundleState(agent);
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const adapterConfig: Record<string, unknown> = {
...current.config,
[PROMPT_KEY]: content,
};
const nextAgent = { ...agent, adapterConfig };
const [bundle, file] = await Promise.all([
getBundle(nextAgent),
readFile(nextAgent, LEGACY_PROMPT_TEMPLATE_PATH),
]);
return { bundle, file, adapterConfig };
}
const prepared = await ensureWritableBundle(agent, options);
const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8");
const nextAgent = { ...agent, adapterConfig: prepared.adapterConfig };
const [bundle, file] = await Promise.all([
getBundle(nextAgent),
readFile(nextAgent, relativePath),
]);
return { bundle, file, adapterConfig: prepared.adapterConfig };
}
async function deleteFile(agent: AgentLike, relativePath: string): Promise<{
bundle: AgentInstructionsBundle;
adapterConfig: Record<string, unknown>;
}> {
const derived = deriveBundleState(agent);
const state = await recoverManagedBundleState(agent, derived);
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
}
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
const normalizedPath = normalizeRelativeFilePath(relativePath);
if (normalizedPath === state.entryFile) {
throw unprocessable("Cannot delete the bundle entry file");
}
const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
await fs.rm(absolutePath, { force: true });
const adapterConfig = buildPersistedBundleConfig(derived, state);
const bundle = await getBundle({ ...agent, adapterConfig });
return { bundle, adapterConfig };
}
async function exportFiles(agent: AgentLike): Promise<{
files: Record<string, string>;
entryFile: string;
warnings: string[];
}> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (state.rootPath) {
const stat = await statIfExists(state.rootPath);
if (stat?.isDirectory()) {
const relativePaths = await listFilesRecursive(state.rootPath);
const files = Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => {
const absolutePath = resolvePathWithinRoot(state.rootPath!, relativePath);
const content = await fs.readFile(absolutePath, "utf8");
return [relativePath, content] as const;
})));
if (Object.keys(files).length > 0) {
return { files, entryFile: state.entryFile, warnings: state.warnings };
}
}
}
const legacyBody = await readLegacyInstructions(agent, state.config);
return {
files: { [state.entryFile]: legacyBody || "_No AGENTS instructions were resolved from current agent config._" },
entryFile: state.entryFile,
warnings: state.warnings,
};
}
async function materializeManagedBundle(
agent: AgentLike,
files: Record<string, string>,
options?: {
clearLegacyPromptTemplate?: boolean;
replaceExisting?: boolean;
entryFile?: string;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
const rootPath = resolveManagedInstructionsRoot(agent);
const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT;
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 };
}
return {
getBundle,
readFile,
updateBundle,
writeFile,
deleteFile,
exportFiles,
ensureManagedBundle: ensureWritableBundle,
materializeManagedBundle,
};
}

View file

@ -6,22 +6,24 @@ import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js";
import { budgetService } from "./budgets.js";
import { notifyHireApproved } from "./hire-hook.js";
function redactApprovalComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
import { instanceSettingsService } from "./instance-settings.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const budgets = budgetService(db);
const instanceSettings = instanceSettingsService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
function redactApprovalComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function getExistingApproval(id: string) {
const existing = await db
.select()
@ -230,6 +232,7 @@ export function approvalService(db: Db) {
listComments: async (approvalId: string) => {
const existing = await getExistingApproval(approvalId);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return db
.select()
.from(approvalComments)
@ -240,7 +243,7 @@ export function approvalService(db: Db) {
),
)
.orderBy(asc(approvalComments.createdAt))
.then((comments) => comments.map(redactApprovalComment));
.then((comments) => comments.map((comment) => redactApprovalComment(comment, censorUsernameInLogs)));
},
addComment: async (
@ -249,7 +252,10 @@ export function approvalService(db: Db) {
actor: { agentId?: string; userId?: string },
) => {
const existing = await getExistingApproval(approvalId);
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
return db
.insert(approvalComments)
.values({
@ -260,7 +266,7 @@ export function approvalService(db: Db) {
body: redactedBody,
})
.returning()
.then((rows) => redactApprovalComment(rows[0]));
.then((rows) => redactApprovalComment(rows[0], currentUserRedactionOptions.enabled));
},
};
}

View file

@ -0,0 +1,354 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { and, eq, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
authUsers,
boardApiKeys,
cliAuthChallenges,
companies,
companyMemberships,
instanceUserRoles,
} from "@paperclipai/db";
import { conflict, forbidden, notFound } from "../errors.js";
export const BOARD_API_KEY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
export const CLI_AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000;
export type CliAuthChallengeStatus = "pending" | "approved" | "cancelled" | "expired";
export function hashBearerToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function tokenHashesMatch(left: string, right: string) {
const leftBytes = Buffer.from(left, "utf8");
const rightBytes = Buffer.from(right, "utf8");
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
}
export function createBoardApiToken() {
return `pcp_board_${randomBytes(24).toString("hex")}`;
}
export function createCliAuthSecret() {
return `pcp_cli_auth_${randomBytes(24).toString("hex")}`;
}
export function boardApiKeyExpiresAt(nowMs: number = Date.now()) {
return new Date(nowMs + BOARD_API_KEY_TTL_MS);
}
export function cliAuthChallengeExpiresAt(nowMs: number = Date.now()) {
return new Date(nowMs + CLI_AUTH_CHALLENGE_TTL_MS);
}
function challengeStatusForRow(row: typeof cliAuthChallenges.$inferSelect): CliAuthChallengeStatus {
if (row.cancelledAt) return "cancelled";
if (row.expiresAt.getTime() <= Date.now()) return "expired";
if (row.approvedAt && row.boardApiKeyId) return "approved";
return "pending";
}
export function boardAuthService(db: Db) {
async function resolveBoardAccess(userId: string) {
const [user, memberships, adminRole] = await Promise.all([
db
.select({
id: authUsers.id,
name: authUsers.name,
email: authUsers.email,
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.then((rows) => rows[0] ?? null),
db
.select({ companyId: companyMemberships.companyId })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
)
.then((rows) => rows.map((row) => row.companyId)),
db
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
.then((rows) => rows[0] ?? null),
]);
return {
user,
companyIds: memberships,
isInstanceAdmin: Boolean(adminRole),
};
}
async function resolveBoardActivityCompanyIds(input: {
userId: string;
requestedCompanyId?: string | null;
boardApiKeyId?: string | null;
}) {
const access = await resolveBoardAccess(input.userId);
const companyIds = new Set(access.companyIds);
if (companyIds.size === 0 && input.requestedCompanyId?.trim()) {
companyIds.add(input.requestedCompanyId.trim());
}
if (companyIds.size === 0 && input.boardApiKeyId?.trim()) {
const challengeCompanyIds = await db
.select({ requestedCompanyId: cliAuthChallenges.requestedCompanyId })
.from(cliAuthChallenges)
.where(eq(cliAuthChallenges.boardApiKeyId, input.boardApiKeyId.trim()))
.then((rows) =>
rows
.map((row) => row.requestedCompanyId?.trim() ?? null)
.filter((value): value is string => Boolean(value)),
);
for (const companyId of challengeCompanyIds) {
companyIds.add(companyId);
}
}
if (companyIds.size === 0 && access.isInstanceAdmin) {
const allCompanyIds = await db
.select({ id: companies.id })
.from(companies)
.then((rows) => rows.map((row) => row.id));
for (const companyId of allCompanyIds) {
companyIds.add(companyId);
}
}
return Array.from(companyIds);
}
async function findBoardApiKeyByToken(token: string) {
const tokenHash = hashBearerToken(token);
const now = new Date();
return db
.select()
.from(boardApiKeys)
.where(
and(
eq(boardApiKeys.keyHash, tokenHash),
isNull(boardApiKeys.revokedAt),
),
)
.then((rows) => rows.find((row) => !row.expiresAt || row.expiresAt.getTime() > now.getTime()) ?? null);
}
async function touchBoardApiKey(id: string) {
await db.update(boardApiKeys).set({ lastUsedAt: new Date() }).where(eq(boardApiKeys.id, id));
}
async function revokeBoardApiKey(id: string) {
const now = new Date();
return db
.update(boardApiKeys)
.set({ revokedAt: now, lastUsedAt: now })
.where(and(eq(boardApiKeys.id, id), isNull(boardApiKeys.revokedAt)))
.returning()
.then((rows) => rows[0] ?? null);
}
async function createCliAuthChallenge(input: {
command: string;
clientName?: string | null;
requestedAccess: "board" | "instance_admin_required";
requestedCompanyId?: string | null;
}) {
const challengeSecret = createCliAuthSecret();
const pendingBoardToken = createBoardApiToken();
const expiresAt = cliAuthChallengeExpiresAt();
const labelBase = input.clientName?.trim() || "paperclipai cli";
const pendingKeyName =
input.requestedAccess === "instance_admin_required"
? `${labelBase} (instance admin)`
: `${labelBase} (board)`;
const created = await db
.insert(cliAuthChallenges)
.values({
secretHash: hashBearerToken(challengeSecret),
command: input.command.trim(),
clientName: input.clientName?.trim() || null,
requestedAccess: input.requestedAccess,
requestedCompanyId: input.requestedCompanyId?.trim() || null,
pendingKeyHash: hashBearerToken(pendingBoardToken),
pendingKeyName,
expiresAt,
})
.returning()
.then((rows) => rows[0]);
return {
challenge: created,
challengeSecret,
pendingBoardToken,
};
}
async function getCliAuthChallenge(id: string) {
return db
.select()
.from(cliAuthChallenges)
.where(eq(cliAuthChallenges.id, id))
.then((rows) => rows[0] ?? null);
}
async function getCliAuthChallengeBySecret(id: string, token: string) {
const challenge = await getCliAuthChallenge(id);
if (!challenge) return null;
if (!tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) return null;
return challenge;
}
async function describeCliAuthChallenge(id: string, token: string) {
const challenge = await getCliAuthChallengeBySecret(id, token);
if (!challenge) return null;
const [company, approvedBy] = await Promise.all([
challenge.requestedCompanyId
? db
.select({ id: companies.id, name: companies.name })
.from(companies)
.where(eq(companies.id, challenge.requestedCompanyId))
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
challenge.approvedByUserId
? db
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, challenge.approvedByUserId))
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
]);
return {
id: challenge.id,
status: challengeStatusForRow(challenge),
command: challenge.command,
clientName: challenge.clientName ?? null,
requestedAccess: challenge.requestedAccess as "board" | "instance_admin_required",
requestedCompanyId: challenge.requestedCompanyId ?? null,
requestedCompanyName: company?.name ?? null,
approvedAt: challenge.approvedAt?.toISOString() ?? null,
cancelledAt: challenge.cancelledAt?.toISOString() ?? null,
expiresAt: challenge.expiresAt.toISOString(),
approvedByUser: approvedBy
? {
id: approvedBy.id,
name: approvedBy.name,
email: approvedBy.email,
}
: null,
};
}
async function approveCliAuthChallenge(id: string, token: string, userId: string) {
const access = await resolveBoardAccess(userId);
return db.transaction(async (tx) => {
await tx.execute(
sql`select ${cliAuthChallenges.id} from ${cliAuthChallenges} where ${cliAuthChallenges.id} = ${id} for update`,
);
const challenge = await tx
.select()
.from(cliAuthChallenges)
.where(eq(cliAuthChallenges.id, id))
.then((rows) => rows[0] ?? null);
if (!challenge || !tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) {
throw notFound("CLI auth challenge not found");
}
const status = challengeStatusForRow(challenge);
if (status === "expired") return { status, challenge };
if (status === "cancelled") return { status, challenge };
if (challenge.requestedAccess === "instance_admin_required" && !access.isInstanceAdmin) {
throw forbidden("Instance admin required");
}
let boardKeyId = challenge.boardApiKeyId;
if (!boardKeyId) {
const createdKey = await tx
.insert(boardApiKeys)
.values({
userId,
name: challenge.pendingKeyName,
keyHash: challenge.pendingKeyHash,
expiresAt: boardApiKeyExpiresAt(),
})
.returning()
.then((rows) => rows[0]);
boardKeyId = createdKey.id;
}
const approvedAt = challenge.approvedAt ?? new Date();
const updated = await tx
.update(cliAuthChallenges)
.set({
approvedByUserId: userId,
boardApiKeyId: boardKeyId,
approvedAt,
updatedAt: new Date(),
})
.where(eq(cliAuthChallenges.id, challenge.id))
.returning()
.then((rows) => rows[0] ?? challenge);
return { status: "approved" as const, challenge: updated };
});
}
async function cancelCliAuthChallenge(id: string, token: string) {
const challenge = await getCliAuthChallengeBySecret(id, token);
if (!challenge) throw notFound("CLI auth challenge not found");
const status = challengeStatusForRow(challenge);
if (status === "approved") return { status, challenge };
if (status === "expired") return { status, challenge };
if (status === "cancelled") return { status, challenge };
const updated = await db
.update(cliAuthChallenges)
.set({
cancelledAt: new Date(),
updatedAt: new Date(),
})
.where(eq(cliAuthChallenges.id, challenge.id))
.returning()
.then((rows) => rows[0] ?? challenge);
return { status: "cancelled" as const, challenge: updated };
}
async function assertCurrentBoardKey(keyId: string | undefined, userId: string | undefined) {
if (!keyId || !userId) throw conflict("Board API key context is required");
const key = await db
.select()
.from(boardApiKeys)
.where(and(eq(boardApiKeys.id, keyId), eq(boardApiKeys.userId, userId)))
.then((rows) => rows[0] ?? null);
if (!key || key.revokedAt) throw notFound("Board API key not found");
return key;
}
return {
resolveBoardAccess,
findBoardApiKeyByToken,
touchBoardApiKey,
revokeBoardApiKey,
createCliAuthChallenge,
getCliAuthChallengeBySecret,
describeCliAuthChallenge,
approveCliAuthChallenge,
cancelCliAuthChallenge,
assertCurrentBoardKey,
resolveBoardActivityCompanyIds,
};
}

View file

@ -0,0 +1,172 @@
/**
* Generates README.md with Mermaid org chart for company exports.
*/
import type { CompanyPortabilityManifest } from "@paperclipai/shared";
const ROLE_LABELS: Record<string, string> = {
ceo: "CEO",
cto: "CTO",
cmo: "CMO",
cfo: "CFO",
coo: "COO",
vp: "VP",
manager: "Manager",
engineer: "Engineer",
agent: "Agent",
};
/**
* Generate a Mermaid flowchart (TD = top-down) representing the org chart.
* Returns null if there are no agents.
*/
export function generateOrgChartMermaid(agents: CompanyPortabilityManifest["agents"]): string | null {
if (agents.length === 0) return null;
const lines: string[] = [];
lines.push("```mermaid");
lines.push("graph TD");
// Node definitions with role labels
for (const agent of agents) {
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
const id = mermaidId(agent.slug);
lines.push(` ${id}["${mermaidEscape(agent.name)}<br/><small>${mermaidEscape(roleLabel)}</small>"]`);
}
// Edges from parent to child
const slugSet = new Set(agents.map((a) => a.slug));
for (const agent of agents) {
if (agent.reportsToSlug && slugSet.has(agent.reportsToSlug)) {
lines.push(` ${mermaidId(agent.reportsToSlug)} --> ${mermaidId(agent.slug)}`);
}
}
lines.push("```");
return lines.join("\n");
}
/** Sanitize slug for use as a Mermaid node ID (alphanumeric + underscore). */
function mermaidId(slug: string): string {
return slug.replace(/[^a-zA-Z0-9_]/g, "_");
}
/** Escape text for Mermaid node labels. */
function mermaidEscape(s: string): string {
return s.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** Build a display label for a skill's source, linking to GitHub when available. */
function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string {
if (skill.sourceLocator) {
// For GitHub or URL sources, render as a markdown link
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url") {
return `[${skill.sourceType}](${skill.sourceLocator})`;
}
return skill.sourceLocator;
}
if (skill.sourceType === "local") return "local";
return skill.sourceType ?? "\u2014";
}
/**
* Generate the README.md content for a company export.
*/
export function generateReadme(
manifest: CompanyPortabilityManifest,
options: {
companyName: string;
companyDescription: string | null;
},
): string {
const lines: string[] = [];
lines.push(`# ${options.companyName}`);
lines.push("");
if (options.companyDescription) {
lines.push(`> ${options.companyDescription}`);
lines.push("");
}
// Org chart image (generated during export as images/org-chart.png)
if (manifest.agents.length > 0) {
lines.push("![Org Chart](images/org-chart.png)");
lines.push("");
}
// What's Inside table
lines.push("## What's Inside");
lines.push("");
lines.push("> This is an [Agent Company](https://agentcompanies.io) package from [Paperclip](https://paperclip.ing)");
lines.push("");
const counts: Array<[string, number]> = [];
if (manifest.agents.length > 0) counts.push(["Agents", manifest.agents.length]);
if (manifest.projects.length > 0) counts.push(["Projects", manifest.projects.length]);
if (manifest.skills.length > 0) counts.push(["Skills", manifest.skills.length]);
if (manifest.issues.length > 0) counts.push(["Tasks", manifest.issues.length]);
if (counts.length > 0) {
lines.push("| Content | Count |");
lines.push("|---------|-------|");
for (const [label, count] of counts) {
lines.push(`| ${label} | ${count} |`);
}
lines.push("");
}
// Agents table
if (manifest.agents.length > 0) {
lines.push("### Agents");
lines.push("");
lines.push("| Agent | Role | Reports To |");
lines.push("|-------|------|------------|");
for (const agent of manifest.agents) {
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
const reportsTo = agent.reportsToSlug ?? "\u2014";
lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`);
}
lines.push("");
}
// Projects list
if (manifest.projects.length > 0) {
lines.push("### Projects");
lines.push("");
for (const project of manifest.projects) {
const desc = project.description ? ` \u2014 ${project.description}` : "";
lines.push(`- **${project.name}**${desc}`);
}
lines.push("");
}
// Skills list
if (manifest.skills.length > 0) {
lines.push("### Skills");
lines.push("");
lines.push("| Skill | Description | Source |");
lines.push("|-------|-------------|--------|");
for (const skill of manifest.skills) {
const desc = skill.description ?? "\u2014";
const source = skillSourceLabel(skill);
lines.push(`| ${skill.name} | ${desc} | ${source} |`);
}
lines.push("");
}
// Getting Started
lines.push("## Getting Started");
lines.push("");
lines.push("```bash");
lines.push("pnpm paperclipai company import this-github-url-or-folder");
lines.push("```");
lines.push("");
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
lines.push("");
// Footer
lines.push("---");
lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`);
lines.push("");
return lines.join("\n");
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
import fs from "node:fs/promises";
const DEFAULT_AGENT_BUNDLE_FILES = {
default: ["AGENTS.md"],
ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"],
} as const;
type DefaultAgentBundleRole = keyof typeof DEFAULT_AGENT_BUNDLE_FILES;
function resolveDefaultAgentBundleUrl(role: DefaultAgentBundleRole, fileName: string) {
return new URL(`../onboarding-assets/${role}/${fileName}`, import.meta.url);
}
export async function loadDefaultAgentInstructionsBundle(role: DefaultAgentBundleRole): Promise<Record<string, string>> {
const fileNames = DEFAULT_AGENT_BUNDLE_FILES[role];
const entries = await Promise.all(
fileNames.map(async (fileName) => {
const content = await fs.readFile(resolveDefaultAgentBundleUrl(role, fileName), "utf8");
return [fileName, content] as const;
}),
);
return Object.fromEntries(entries);
}
export function resolveDefaultAgentInstructionsBundleRole(role: string): DefaultAgentBundleRole {
return role === "ceo" ? "ceo" : "default";
}

View file

@ -132,6 +132,21 @@ export function defaultIssueExecutionWorkspaceSettingsForProject(
};
}
export function issueExecutionWorkspaceModeForPersistedWorkspace(
mode: string | null | undefined,
): IssueExecutionWorkspaceSettings["mode"] {
if (mode === null || mode === undefined) {
return "agent_default";
}
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
return mode;
}
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
return "agent_default";
}
return "shared_workspace";
}
export function resolveExecutionWorkspaceMode(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;

View file

@ -25,6 +25,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
@ -44,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js";
import {
buildExecutionWorkspaceAdapterConfig,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
@ -324,6 +326,51 @@ async function resolveLedgerScopeForRun(
};
}
type ResumeSessionRow = {
sessionParamsJson: Record<string, unknown> | null;
sessionDisplayId: string | null;
lastRunId: string | null;
};
export function buildExplicitResumeSessionOverride(input: {
resumeFromRunId: string;
resumeRunSessionIdBefore: string | null;
resumeRunSessionIdAfter: string | null;
taskSession: ResumeSessionRow | null;
sessionCodec: AdapterSessionCodec;
}) {
const desiredDisplayId = truncateDisplayId(
input.resumeRunSessionIdAfter ?? input.resumeRunSessionIdBefore,
);
const taskSessionParams = normalizeSessionParams(
input.sessionCodec.deserialize(input.taskSession?.sessionParamsJson ?? null),
);
const taskSessionDisplayId = truncateDisplayId(
input.taskSession?.sessionDisplayId ??
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
readNonEmptyString(taskSessionParams?.sessionId),
);
const canReuseTaskSessionParams =
input.taskSession != null &&
(
input.taskSession.lastRunId === input.resumeFromRunId ||
(!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)
);
const sessionParams =
canReuseTaskSessionParams
? taskSessionParams
: desiredDisplayId
? { sessionId: desiredDisplayId }
: null;
const sessionDisplayId = desiredDisplayId ?? (canReuseTaskSessionParams ? taskSessionDisplayId : null);
if (!sessionDisplayId && !sessionParams) return null;
return {
sessionDisplayId,
sessionParams,
};
}
function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
if (!usage) return null;
return {
@ -720,9 +767,13 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const getCurrentUserRedactionOptions = async () => ({
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
});
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const companySkills = companySkillService(db);
const issuesSvc = issueService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
const workspaceOperationsSvc = workspaceOperationService(db);
@ -972,6 +1023,57 @@ export function heartbeatService(db: Db) {
return runtimeForRun?.sessionId ?? null;
}
async function resolveExplicitResumeSessionOverride(
agent: typeof agents.$inferSelect,
payload: Record<string, unknown> | null,
taskKey: string | null,
) {
const resumeFromRunId = readNonEmptyString(payload?.resumeFromRunId);
if (!resumeFromRunId) return null;
const resumeRun = await db
.select({
id: heartbeatRuns.id,
contextSnapshot: heartbeatRuns.contextSnapshot,
sessionIdBefore: heartbeatRuns.sessionIdBefore,
sessionIdAfter: heartbeatRuns.sessionIdAfter,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, resumeFromRunId),
eq(heartbeatRuns.companyId, agent.companyId),
eq(heartbeatRuns.agentId, agent.id),
),
)
.then((rows) => rows[0] ?? null);
if (!resumeRun) return null;
const resumeContext = parseObject(resumeRun.contextSnapshot);
const resumeTaskKey = deriveTaskKey(resumeContext, null) ?? taskKey;
const resumeTaskSession = resumeTaskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey)
: null;
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
const sessionOverride = buildExplicitResumeSessionOverride({
resumeFromRunId,
resumeRunSessionIdBefore: resumeRun.sessionIdBefore,
resumeRunSessionIdAfter: resumeRun.sessionIdAfter,
taskSession: resumeTaskSession,
sessionCodec,
});
if (!sessionOverride) return null;
return {
resumeFromRunId,
taskKey: resumeTaskKey,
issueId: readNonEmptyString(resumeContext.issueId),
taskId: readNonEmptyString(resumeContext.taskId) ?? readNonEmptyString(resumeContext.issueId),
sessionDisplayId: sessionOverride.sessionDisplayId,
sessionParams: sessionOverride.sessionParams,
};
}
async function resolveWorkspaceForRun(
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
@ -1318,8 +1420,13 @@ export function heartbeatService(db: Db) {
payload?: Record<string, unknown>;
},
) {
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message;
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload;
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const sanitizedMessage = event.message
? redactCurrentUserText(event.message, currentUserRedactionOptions)
: event.message;
const sanitizedPayload = event.payload
? redactCurrentUserValue(event.payload, currentUserRedactionOptions)
: event.payload;
await db.insert(heartbeatRunEvents).values({
companyId: run.companyId,
@ -1910,9 +2017,18 @@ export function heartbeatService(db: Db) {
const resetTaskSession = shouldResetTaskSessionForWake(context);
const sessionResetReason = describeSessionResetReason(context);
const taskSessionForRun = resetTaskSession ? null : taskSession;
const previousSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
const explicitResumeSessionParams = normalizeSessionParams(
sessionCodec.deserialize(parseObject(context.resumeSessionParams)),
);
const explicitResumeSessionDisplayId = truncateDisplayId(
readNonEmptyString(context.resumeSessionDisplayId) ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(explicitResumeSessionParams) : null) ??
readNonEmptyString(explicitResumeSessionParams?.sessionId),
);
const previousSessionParams =
explicitResumeSessionParams ??
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
const config = parseObject(agent.adapterConfig);
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy,
@ -1939,6 +2055,11 @@ export function heartbeatService(db: Db) {
agent.companyId,
mergedConfig,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
const issueRef = issueContext
? {
id: issueContext.id,
@ -1966,7 +2087,7 @@ export function heartbeatService(db: Db) {
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
},
config: resolvedConfig,
config: runtimeConfig,
issue: issueRef,
agent: {
id: agent.id,
@ -2083,11 +2204,29 @@ export function heartbeatService(db: Db) {
cleanupReason: null,
});
}
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
await issuesSvc.update(issueId, {
executionWorkspaceId: persistedExecutionWorkspace.id,
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}),
});
if (issueId && persistedExecutionWorkspace) {
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
const shouldSwitchIssueToExistingWorkspace =
issueRef?.executionWorkspacePreference === "reuse_existing" ||
executionWorkspaceMode === "isolated_workspace" ||
executionWorkspaceMode === "operator_branch";
const nextIssuePatch: Record<string, unknown> = {};
if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id;
}
if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) {
nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId;
}
if (shouldSwitchIssueToExistingWorkspace) {
nextIssuePatch.executionWorkspacePreference = "reuse_existing";
nextIssuePatch.executionWorkspaceSettings = {
...(issueExecutionWorkspaceSettings ?? {}),
mode: nextIssueWorkspaceMode,
};
}
if (Object.keys(nextIssuePatch).length > 0) {
await issuesSvc.update(issueId, nextIssuePatch);
}
}
if (persistedExecutionWorkspace) {
context.executionWorkspaceId = persistedExecutionWorkspace.id;
@ -2131,7 +2270,11 @@ export function heartbeatService(db: Db) {
repoRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
worktreePath: executionWorkspace.worktreePath,
agentHome: resolveDefaultAgentWorkspaceDir(agent.id),
agentHome: await (async () => {
const home = resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(home, { recursive: true });
return home;
})(),
};
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
const runtimeServiceIntents = (() => {
@ -2152,7 +2295,8 @@ export function heartbeatService(db: Db) {
}
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
let previousSessionDisplayId = truncateDisplayId(
taskSessionForRun?.sessionDisplayId ??
explicitResumeSessionDisplayId ??
taskSessionForRun?.sessionDisplayId ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
readNonEmptyString(runtimeSessionParams?.sessionId) ??
runtimeSessionFallback,
@ -2252,8 +2396,9 @@ export function heartbeatService(db: Db) {
})
.where(eq(heartbeatRuns.id, runId));
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
const sanitizedChunk = redactCurrentUserText(chunk);
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString();
@ -2371,7 +2516,7 @@ export function heartbeatService(db: Db) {
runId: run.id,
agent,
runtime: runtimeForAdapter,
config: resolvedConfig,
config: runtimeConfig,
context,
onLog,
onMeta: onAdapterMeta,
@ -2503,6 +2648,7 @@ export function heartbeatService(db: Db) {
? null
: redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
currentUserRedactionOptions,
),
errorCode:
outcome === "timed_out"
@ -2570,7 +2716,10 @@ export function heartbeatService(db: Db) {
}
await finalizeAgentStatus(agent.id, outcome);
} catch (err) {
const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure");
const message = redactCurrentUserText(
err instanceof Error ? err.message : "Unknown adapter failure",
await getCurrentUserRedactionOptions(),
);
logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@ -2758,7 +2907,9 @@ export function heartbeatService(db: Db) {
payload: promotedPayload,
});
const sessionBefore = await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
const sessionBefore =
readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ??
await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
const now = new Date();
const newRun = await tx
.insert(heartbeatRuns)
@ -2837,10 +2988,30 @@ export function heartbeatService(db: Db) {
triggerDetail,
payload,
});
const issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
const explicitResumeSession = await resolveExplicitResumeSessionOverride(agent, payload, taskKey);
if (explicitResumeSession) {
enrichedContextSnapshot.resumeFromRunId = explicitResumeSession.resumeFromRunId;
enrichedContextSnapshot.resumeSessionDisplayId = explicitResumeSession.sessionDisplayId;
enrichedContextSnapshot.resumeSessionParams = explicitResumeSession.sessionParams;
if (!readNonEmptyString(enrichedContextSnapshot.issueId) && explicitResumeSession.issueId) {
enrichedContextSnapshot.issueId = explicitResumeSession.issueId;
}
if (!readNonEmptyString(enrichedContextSnapshot.taskId) && explicitResumeSession.taskId) {
enrichedContextSnapshot.taskId = explicitResumeSession.taskId;
}
if (!readNonEmptyString(enrichedContextSnapshot.taskKey) && explicitResumeSession.taskKey) {
enrichedContextSnapshot.taskKey = explicitResumeSession.taskKey;
}
issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueId;
}
const effectiveTaskKey = readNonEmptyString(enrichedContextSnapshot.taskKey) ?? taskKey;
const sessionBefore =
explicitResumeSession?.sessionDisplayId ??
await resolveSessionBeforeForWakeup(agent, effectiveTaskKey);
const writeSkippedRequest = async (skipReason: string) => {
await db.insert(agentWakeupRequests).values({
@ -2904,7 +3075,6 @@ export function heartbeatService(db: Db) {
if (issueId && !bypassIssueExecutionLock) {
const agentNameKey = normalizeAgentNameKey(agent.name);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const outcome = await db.transaction(async (tx) => {
await tx.execute(
@ -3255,8 +3425,6 @@ export function heartbeatService(db: Db) {
.returning()
.then((rows) => rows[0]);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const newRun = await db
.insert(heartbeatRuns)
.values({
@ -3608,7 +3776,7 @@ export function heartbeatService(db: Db) {
store: run.logStore,
logRef: run.logRef,
...result,
content: redactCurrentUserText(result.content),
content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()),
};
},

View file

@ -1,5 +1,7 @@
export { companyService } from "./companies.js";
export { companySkillService } from "./company-skills.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
export { assetService } from "./assets.js";
export { documentService, extractLegacyPlanBody } from "./documents.js";
export { projectService } from "./projects.js";
@ -10,12 +12,14 @@ export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";
export { budgetService } from "./budgets.js";
export { secretService } from "./secrets.js";
export { routineService } from "./routines.js";
export { costService } from "./costs.js";
export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
export { companyPortabilityService } from "./company-portability.js";
export { executionWorkspaceService } from "./execution-workspaces.js";

View file

@ -1,8 +1,11 @@
import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
type InstanceExperimentalSettings,
type PatchInstanceGeneralSettings,
type InstanceSettings,
type PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
@ -10,21 +13,36 @@ import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default";
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
};
}
return {
censorUsernameInLogs: false,
};
}
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
};
}
return {
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
};
}
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
return {
id: row.id,
general: normalizeGeneralSettings(row.general),
experimental: normalizeExperimentalSettings(row.experimental),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@ -45,6 +63,7 @@ export function instanceSettingsService(db: Db) {
.insert(instanceSettings)
.values({
singletonKey: DEFAULT_SINGLETON_KEY,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
@ -63,11 +82,34 @@ export function instanceSettingsService(db: Db) {
return {
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
getGeneral: async (): Promise<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
return normalizeGeneralSettings(row.general);
},
getExperimental: async (): Promise<InstanceExperimentalSettings> => {
const row = await getOrCreateRow();
return normalizeExperimentalSettings(row.experimental);
},
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextGeneral = normalizeGeneralSettings({
...normalizeGeneralSettings(current.general),
...patch,
});
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
general: { ...nextGeneral },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextExperimental = normalizeExperimentalSettings({

View file

@ -0,0 +1,48 @@
import { logger } from "../middleware/logger.js";
type WakeupTriggerDetail = "manual" | "ping" | "callback" | "system";
type WakeupSource = "timer" | "assignment" | "on_demand" | "automation";
export interface IssueAssignmentWakeupDeps {
wakeup: (
agentId: string,
opts: {
source?: WakeupSource;
triggerDetail?: WakeupTriggerDetail;
reason?: string | null;
payload?: Record<string, unknown> | null;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
},
) => Promise<unknown>;
}
export function queueIssueAssignmentWakeup(input: {
heartbeat: IssueAssignmentWakeupDeps;
issue: { id: string; assigneeAgentId: string | null; status: string };
reason: string;
mutation: string;
contextSource: string;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
rethrowOnError?: boolean;
}) {
if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return;
return input.heartbeat
.wakeup(input.issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: input.reason,
payload: { issueId: input.issue.id, mutation: input.mutation },
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId ?? null,
contextSnapshot: { issueId: input.issue.id, source: input.contextSource },
})
.catch((err) => {
logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment");
if (input.rethrowOnError) throw err;
return null;
});
}

View file

@ -1,6 +1,7 @@
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
assets,
companies,
@ -19,7 +20,8 @@ import {
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { extractProjectMentionIds } from "@paperclipai/shared";
import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared";
import { decodeHTMLStrict } from "entities";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
@ -62,12 +64,16 @@ function applyStatusSideEffects(
export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
parentId?: string;
labelId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
}
@ -97,13 +103,6 @@ type IssueUserContextInput = {
updatedAt: Date | string;
};
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
@ -138,6 +137,30 @@ function touchedByUserCondition(companyId: string, userId: string) {
`;
}
function participatedByAgentCondition(companyId: string, agentId: string) {
return sql<boolean>`
(
${issues.createdByAgentId} = ${agentId}
OR ${issues.assigneeAgentId} = ${agentId}
OR EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.authorAgentId} = ${agentId}
)
OR EXISTS (
SELECT 1
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.agentId} = ${agentId}
)
)
`;
}
function myLastCommentAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
@ -196,38 +219,12 @@ function unreadForUserCondition(companyId: string, userId: string) {
`;
}
/** Named entities the rich-text editor may emit in issue bodies; unknown names are left unchanged. */
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
nbsp: "\u00A0",
quot: '"',
ensp: "\u2002",
emsp: "\u2003",
thinsp: "\u2009",
};
function decodeNumericHtmlEntity(digits: string, radix: 16 | 10): string | null {
const n = Number.parseInt(digits, radix);
if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return null;
try {
return String.fromCodePoint(n);
} catch {
return null;
}
}
/** Decodes HTML entities in a raw @mention capture so UI-encoded bodies still match agent names. */
/**
* Decodes HTML character references in a raw @mention capture (WHATWG HTML, strict semicolon form)
* so rich-text / UI-encoded bodies still match agent names.
*/
export function normalizeAgentMentionToken(raw: string): string {
let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex: string) => decodeNumericHtmlEntity(hex, 16) ?? full);
s = s.replace(/&#([0-9]+);/g, (full, dec: string) => decodeNumericHtmlEntity(dec, 10) ?? full);
s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name: string) => {
const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()];
return decoded !== undefined ? decoded : full;
});
return s.trim();
return decodeHTMLStrict(raw).trim();
}
export function deriveIssueUserContext(
@ -354,6 +351,13 @@ function withActiveRuns(
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
.select({
@ -539,6 +543,9 @@ export function issueService(db: Db) {
if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}
if (filters?.participantAgentId) {
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
}
if (filters?.assigneeUserId) {
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
}
@ -550,6 +557,8 @@ export function issueService(db: Db) {
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })
@ -568,6 +577,9 @@ export function issueService(db: Db) {
)!,
);
}
if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
conditions.push(isNull(issues.hiddenAt));
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
@ -649,6 +661,7 @@ export function issueService(db: Db) {
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
unreadForUserCondition(companyId, userId),
ne(issues.originKind, "routine_execution"),
];
if (status) {
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
@ -787,6 +800,7 @@ export function issueService(db: Db) {
const values = {
...issueData,
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
@ -1249,7 +1263,8 @@ export function issueService(db: Db) {
);
const comments = limit ? await query.limit(limit) : await query;
return comments.map(redactIssueComment);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
},
getCommentCursor: async (issueId: string) => {
@ -1281,14 +1296,15 @@ export function issueService(db: Db) {
},
getComment: (commentId: string) =>
db
instanceSettings.getGeneral().then(({ censorUsernameInLogs }) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => {
const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null;
}),
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
@ -1299,7 +1315,10 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found");
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const [comment] = await db
.insert(issueComments)
.values({
@ -1317,7 +1336,7 @@ export function issueService(db: Db) {
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));
return redactIssueComment(comment);
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
},
createAttachment: async (input: {
@ -1484,10 +1503,18 @@ export function issueService(db: Db) {
const normalized = normalizeAgentMentionToken(m[1]);
if (normalized) tokens.add(normalized.toLowerCase());
}
if (tokens.size === 0) return [];
const explicitAgentMentionIds = extractAgentMentionIds(body);
if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return [];
const rows = await db.select({ id: agents.id, name: agents.name })
.from(agents).where(eq(agents.companyId, companyId));
return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id);
const resolved = new Set<string>(explicitAgentMentionIds);
for (const agent of rows) {
if (tokens.has(agent.name.toLowerCase())) {
resolved.add(agent.id);
}
}
return [...resolved];
},
findMentionedProjectIds: async (issueId: string) => {

File diff suppressed because it is too large Load diff

View file

@ -159,6 +159,7 @@ export function secretService(db: Db) {
getById,
getByName,
resolveSecretValue,
create: async (
companyId: string,

View file

@ -5,6 +5,7 @@ import type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationSta
import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm";
import { notFound } from "../errors.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { instanceSettingsService } from "./instance-settings.js";
import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js";
type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
@ -69,6 +70,7 @@ export interface WorkspaceOperationRecorder {
}
export function workspaceOperationService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const logStore = getWorkspaceOperationLogStore();
async function getById(id: string) {
@ -105,6 +107,9 @@ export function workspaceOperationService(db: Db) {
},
async recordOperation(recordInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const startedAt = new Date();
const id = randomUUID();
const handle = await logStore.begin({
@ -116,7 +121,7 @@ export function workspaceOperationService(db: Db) {
let stderrExcerpt = "";
const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => {
if (!chunk) return;
const sanitizedChunk = redactCurrentUserText(chunk);
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
await logStore.append(handle, {
@ -137,7 +142,10 @@ export function workspaceOperationService(db: Db) {
status: "running",
logStore: handle.store,
logRef: handle.logRef,
metadata: redactCurrentUserValue(recordInput.metadata ?? null) as Record<string, unknown> | null,
metadata: redactCurrentUserValue(
recordInput.metadata ?? null,
currentUserRedactionOptions,
) as Record<string, unknown> | null,
startedAt,
});
createdIds.push(id);
@ -162,6 +170,7 @@ export function workspaceOperationService(db: Db) {
logCompressed: finalized.compressed,
metadata: redactCurrentUserValue(
combineMetadata(recordInput.metadata, result.metadata),
currentUserRedactionOptions,
) as Record<string, unknown> | null,
finishedAt,
updatedAt: finishedAt,
@ -241,7 +250,9 @@ export function workspaceOperationService(db: Db) {
store: operation.logStore,
logRef: operation.logRef,
...result,
content: redactCurrentUserText(result.content),
content: redactCurrentUserText(result.content, {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
}),
};
},
};

View file

@ -12,7 +12,7 @@ declare global {
isInstanceAdmin?: boolean;
keyId?: string;
runId?: string;
source?: "local_implicit" | "session" | "agent_key" | "agent_jwt" | "none";
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "none";
};
}
}