mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Merge branch 'master' into add-gpt-5-4-xhigh-effort
This commit is contained in:
commit
19aaa54ae4
882 changed files with 316479 additions and 9403 deletions
|
|
@ -1,5 +1,42 @@
|
|||
# @paperclipai/server
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
- @paperclipai/adapter-claude-local@0.3.1
|
||||
- @paperclipai/adapter-codex-local@0.3.1
|
||||
- @paperclipai/adapter-cursor-local@0.3.1
|
||||
- @paperclipai/adapter-gemini-local@0.3.1
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.1
|
||||
- @paperclipai/adapter-opencode-local@0.3.1
|
||||
- @paperclipai/adapter-pi-local@0.3.1
|
||||
- @paperclipai/db@0.3.1
|
||||
- @paperclipai/shared@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6077ae6]
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.3.0
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
- @paperclipai/adapter-claude-local@0.3.0
|
||||
- @paperclipai/adapter-codex-local@0.3.0
|
||||
- @paperclipai/adapter-cursor-local@0.3.0
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.0
|
||||
- @paperclipai/adapter-opencode-local@0.3.0
|
||||
- @paperclipai/adapter-pi-local@0.3.0
|
||||
- @paperclipai/db@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
{
|
||||
"name": "@paperclipai/server",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "server"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
|
@ -22,45 +32,61 @@
|
|||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
|
||||
"build": "tsc",
|
||||
"preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts",
|
||||
"dev": "pnpm run preflight:workspace-links && tsx src/index.ts",
|
||||
"dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
||||
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||
"build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
|
||||
"prepack": "pnpm run prepare:ui-dist",
|
||||
"postpack": "rm -rf ui-dist",
|
||||
"clean": "rm -rf dist",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-auth": "1.4.18",
|
||||
"chokidar": "^4.0.3",
|
||||
"detect-port": "^2.1.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^17.0.1",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"express": "^5.1.0",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"open": "^11.0.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.4.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"sharp": "^0.34.5",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/jsdom": "^28.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
|
|
|
|||
33
server/scripts/dev-watch.ts
Normal file
33
server/scripts/dev-watch.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const tsxCliPath = require.resolve("tsx/cli");
|
||||
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[tsxCliPath, "watch", ...ignoreArgs, "src/index.ts"],
|
||||
{
|
||||
cwd: serverRoot,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
70
server/src/__tests__/activity-routes.test.ts
Normal file
70
server/src/__tests__/activity-routes.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { activityRoutes } from "../routes/activity.js";
|
||||
|
||||
const mockActivityService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
forIssue: vi.fn(),
|
||||
runsForIssue: vi.fn(),
|
||||
issuesForRun: vi.fn(),
|
||||
create: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity.js", () => ({
|
||||
activityService: () => mockActivityService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
issueService: () => mockIssueService,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", activityRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("activity routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves issue identifiers before loading runs", async () => {
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-uuid-1",
|
||||
companyId: "company-1",
|
||||
});
|
||||
mockActivityService.runsForIssue.mockResolvedValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(createApp()).get("/api/issues/PAP-475/runs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
|
||||
expect(mockIssueService.getById).not.toHaveBeenCalled();
|
||||
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1");
|
||||
expect(res.body).toEqual([{ runId: "run-1" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,10 @@ import {
|
|||
sessionCodec as cursorSessionCodec,
|
||||
isCursorUnknownSessionError,
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import {
|
||||
sessionCodec as geminiSessionCodec,
|
||||
isGeminiUnknownSessionError,
|
||||
} from "@paperclipai/adapter-gemini-local/server";
|
||||
import {
|
||||
sessionCodec as opencodeSessionCodec,
|
||||
isOpenCodeUnknownSessionError,
|
||||
|
|
@ -82,6 +86,24 @@ describe("adapter session codecs", () => {
|
|||
});
|
||||
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1");
|
||||
});
|
||||
|
||||
it("normalizes gemini session params with cwd", () => {
|
||||
const parsed = geminiSessionCodec.deserialize({
|
||||
session_id: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
|
||||
const serialized = geminiSessionCodec.serialize(parsed);
|
||||
expect(serialized).toEqual({
|
||||
sessionId: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex resume recovery detection", () => {
|
||||
|
|
@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini resume recovery detection", () => {
|
||||
it("detects unknown session errors from gemini output", () => {
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"",
|
||||
"unknown session id abc",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"",
|
||||
"checkpoint latest not found",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"{\"type\":\"result\",\"subtype\":\"success\"}",
|
||||
"",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
318
server/src/__tests__/agent-instructions-routes.test.ts
Normal file
318
server/src/__tests__/agent-instructions-routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
361
server/src/__tests__/agent-instructions-service.test.ts
Normal file
361
server/src/__tests__/agent-instructions-service.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
314
server/src/__tests__/agent-permissions-routes.test.ts
Normal file
314
server/src/__tests__/agent-permissions-routes.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const baseAgent = {
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Builder",
|
||||
urlKey: "builder",
|
||||
role: "engineer",
|
||||
title: "Builder",
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updatePermissions: vi.fn(),
|
||||
getChainOfCommand: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
listPrincipalGrants: vi.fn(),
|
||||
setPrincipalPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
listTaskSessions: vi.fn(),
|
||||
resetRuntimeSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
linkManyForApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeAdapterConfigForPersistence: vi.fn(),
|
||||
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,
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue([{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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", agentRoutes(createDbStub() as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("agent permission routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
||||
mockAgentService.getChainOfCommand.mockResolvedValue([]);
|
||||
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
|
||||
mockAgentService.create.mockResolvedValue(baseAgent);
|
||||
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
|
||||
mockAccessService.getMembership.mockResolvedValue({
|
||||
id: "membership-1",
|
||||
companyId,
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it("grants tasks:assign by default when board creates a new agent", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAccessService.ensureMembership).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"agent",
|
||||
agentId,
|
||||
"member",
|
||||
"active",
|
||||
);
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"agent",
|
||||
agentId,
|
||||
"tasks:assign",
|
||||
true,
|
||||
"board-user",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes explicit task assignment access on agent detail", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||
{
|
||||
id: "grant-1",
|
||||
companyId,
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
grantedByUserId: "board-user",
|
||||
createdAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/agents/${agentId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.access.canAssignTasks).toBe(true);
|
||||
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
|
||||
});
|
||||
|
||||
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
|
||||
mockAgentService.updatePermissions.mockResolvedValue({
|
||||
...baseAgent,
|
||||
permissions: { canCreateAgents: true },
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/agents/${agentId}/permissions`)
|
||||
.send({ canCreateAgents: true, canAssignTasks: false });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
"agent",
|
||||
agentId,
|
||||
"tasks:assign",
|
||||
true,
|
||||
"board-user",
|
||||
);
|
||||
expect(res.body.access.canAssignTasks).toBe(true);
|
||||
expect(res.body.access.taskAssignSource).toBe("agent_creator");
|
||||
});
|
||||
|
||||
it("exposes a dedicated agent route for the inbox mine view", async () => {
|
||||
mockIssueService.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-910",
|
||||
title: "Inbox follow-up",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
runId: "run-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/agents/me/inbox/mine")
|
||||
.query({ userId: "board-user" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
|
||||
touchedByUserId: "board-user",
|
||||
inboxArchivedByUserId: "board-user",
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
});
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-910",
|
||||
title: "Inbox follow-up",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
49
server/src/__tests__/agent-skill-contract.test.ts
Normal file
49
server/src/__tests__/agent-skill-contract.test.ts
Normal 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",
|
||||
}],
|
||||
});
|
||||
});
|
||||
});
|
||||
462
server/src/__tests__/agent-skills-routes.test.ts
Normal file
462
server/src/__tests__/agent-skills-routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
19
server/src/__tests__/app-hmr-port.test.ts
Normal file
19
server/src/__tests__/app-hmr-port.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveViteHmrPort } from "../app.ts";
|
||||
|
||||
describe("resolveViteHmrPort", () => {
|
||||
it("uses serverPort + 10000 when the result stays in range", () => {
|
||||
expect(resolveViteHmrPort(3100)).toBe(13_100);
|
||||
expect(resolveViteHmrPort(55_535)).toBe(65_535);
|
||||
});
|
||||
|
||||
it("falls back below the server port when adding 10000 would overflow", () => {
|
||||
expect(resolveViteHmrPort(55_536)).toBe(45_536);
|
||||
expect(resolveViteHmrPort(63_000)).toBe(53_000);
|
||||
});
|
||||
|
||||
it("never returns a privileged or invalid port", () => {
|
||||
expect(resolveViteHmrPort(65_535)).toBe(55_535);
|
||||
expect(resolveViteHmrPort(9_000)).toBe(19_000);
|
||||
});
|
||||
});
|
||||
110
server/src/__tests__/approval-routes-idempotency.test.ts
Normal file
110
server/src/__tests__/approval-routes-idempotency.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalRoutes } from "../routes/approvals.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
approve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
requestRevision: vi.fn(),
|
||||
resubmit: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
listIssuesForApproval: vi.fn(),
|
||||
linkManyForApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeHireApprovalPayloadForPersistence: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
approvalService: () => mockApprovalService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", approvalRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("approval routes idempotent retries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
|
||||
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("does not emit duplicate approval side effects when approve is already resolved", async () => {
|
||||
mockApprovalService.approve.mockResolvedValue({
|
||||
approval: {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status: "approved",
|
||||
payload: {},
|
||||
requestedByAgentId: "agent-1",
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/approvals/approval-1/approve")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueApprovalService.listIssuesForApproval).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit duplicate rejection logs when reject is already resolved", async () => {
|
||||
mockApprovalService.reject.mockResolvedValue({
|
||||
approval: {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status: "rejected",
|
||||
payload: {},
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/approvals/approval-1/reject")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
107
server/src/__tests__/approvals-service.test.ts
Normal file
107
server/src/__tests__/approvals-service.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
activatePendingApproval: vi.fn(),
|
||||
create: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNotifyHireApproved = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/agents.js", () => ({
|
||||
agentService: vi.fn(() => mockAgentService),
|
||||
}));
|
||||
|
||||
vi.mock("../services/hire-hook.js", () => ({
|
||||
notifyHireApproved: mockNotifyHireApproved,
|
||||
}));
|
||||
|
||||
type ApprovalRecord = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
payload: Record<string, unknown>;
|
||||
requestedByAgentId: string | null;
|
||||
};
|
||||
|
||||
function createApproval(status: string): ApprovalRecord {
|
||||
return {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status,
|
||||
payload: { agentId: "agent-1" },
|
||||
requestedByAgentId: "requester-1",
|
||||
};
|
||||
}
|
||||
|
||||
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
|
||||
const pendingSelectResults = [...selectResults];
|
||||
const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []);
|
||||
const from = vi.fn(() => ({ where: selectWhere }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
|
||||
const returning = vi.fn(async () => updateResults);
|
||||
const updateWhere = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn(() => ({ where: updateWhere }));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
|
||||
return {
|
||||
db: { select, update },
|
||||
selectWhere,
|
||||
returning,
|
||||
};
|
||||
}
|
||||
|
||||
describe("approvalService resolution idempotency", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.activatePendingApproval.mockResolvedValue(undefined);
|
||||
mockAgentService.create.mockResolvedValue({ id: "agent-1" });
|
||||
mockAgentService.terminate.mockResolvedValue(undefined);
|
||||
mockNotifyHireApproved.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("treats repeated approve retries as no-ops after another worker resolves the approval", async () => {
|
||||
const dbStub = createDbStub(
|
||||
[[createApproval("pending")], [createApproval("approved")]],
|
||||
[],
|
||||
);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.approval.status).toBe("approved");
|
||||
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
|
||||
expect(mockNotifyHireApproved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats repeated reject retries as no-ops after another worker resolves the approval", async () => {
|
||||
const dbStub = createDbStub(
|
||||
[[createApproval("pending")], [createApproval("rejected")]],
|
||||
[],
|
||||
);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.reject("approval-1", "board", "not now");
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.approval.status).toBe("rejected");
|
||||
expect(mockAgentService.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still performs side effects when the resolution update is newly applied", async () => {
|
||||
const approved = createApproval("approved");
|
||||
const dbStub = createDbStub([[createApproval("pending")]], [approved]);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1");
|
||||
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
250
server/src/__tests__/assets.test.ts
Normal file
250
server/src/__tests__/assets.test.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { assetRoutes } from "../routes/assets.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => ({
|
||||
createAssetMock: vi.fn(),
|
||||
getAssetByIdMock: vi.fn(),
|
||||
logActivityMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
assetService: vi.fn(() => ({
|
||||
create: createAssetMock,
|
||||
getById: getAssetByIdMock,
|
||||
})),
|
||||
logActivity: logActivityMock,
|
||||
}));
|
||||
|
||||
function createAsset() {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
id: "asset-1",
|
||||
companyId: "company-1",
|
||||
provider: "local",
|
||||
objectKey: "assets/abc",
|
||||
contentType: "image/png",
|
||||
byteSize: 40,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: "logo.png",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function createStorageService(contentType = "image/png"): StorageService {
|
||||
const putFile: StorageService["putFile"] = vi.fn(async (input: {
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename: string | null;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}) => {
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
|
||||
contentType: contentType || input.contentType,
|
||||
byteSize: input.body.length,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: input.originalFilename,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
putFile,
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
deleteObject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId: "user-1",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", assetRoutes({} as any, storage));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
afterEach(() => {
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts PNG image uploads and returns an asset path", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "goals")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/goals",
|
||||
originalFilename: "logo.png",
|
||||
contentType: "image/png",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("allows supported non-image attachments outside the company logo flow", async () => {
|
||||
const text = createStorageService("text/plain");
|
||||
const app = createApp(text);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "text/plain",
|
||||
originalFilename: "note.txt",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "issues/drafts")
|
||||
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(text.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/issues/drafts",
|
||||
originalFilename: "note.txt",
|
||||
contentType: "text/plain",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/companies/:companyId/logo", () => {
|
||||
afterEach(() => {
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts PNG logo uploads and returns an asset path", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/companies",
|
||||
originalFilename: "logo.png",
|
||||
contentType: "image/png",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes SVG logo uploads before storing them", async () => {
|
||||
const svg = createStorageService("image/svg+xml");
|
||||
const app = createApp(svg);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "image/svg+xml",
|
||||
originalFilename: "logo.svg",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach(
|
||||
"file",
|
||||
Buffer.from(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' onload='alert(1)'><script>alert(1)</script><a href='https://evil.example/'><circle cx='12' cy='12' r='10'/></a></svg>",
|
||||
),
|
||||
"logo.svg",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(svg.putFile).toHaveBeenCalledTimes(1);
|
||||
const stored = (svg.putFile as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
expect(stored.contentType).toBe("image/svg+xml");
|
||||
expect(stored.originalFilename).toBe("logo.svg");
|
||||
const body = stored.body.toString("utf8");
|
||||
expect(body).toContain("<svg");
|
||||
expect(body).toContain("<circle");
|
||||
expect(body).not.toContain("<script");
|
||||
expect(body).not.toContain("onload=");
|
||||
expect(body).not.toContain("https://evil.example/");
|
||||
});
|
||||
|
||||
it("allows logo uploads within the general attachment limit", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(150 * 1024, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "within-limit.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects logo files larger than the general attachment limit", async () => {
|
||||
const app = createApp(createStorageService());
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "too-large.png");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe(`Image exceeds ${MAX_ATTACHMENT_BYTES} bytes`);
|
||||
});
|
||||
|
||||
it("rejects unsupported image types", async () => {
|
||||
const app = createApp(createStorageService("text/plain"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not an image"), "note.txt");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Unsupported image type: text/plain");
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SVG image uploads that cannot be sanitized", async () => {
|
||||
const app = createApp(createStorageService("image/svg+xml"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not actually svg"), "logo.svg");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("SVG could not be sanitized");
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
97
server/src/__tests__/attachment-types.test.ts
Normal file
97
server/src/__tests__/attachment-types.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseAllowedTypes,
|
||||
matchesContentType,
|
||||
DEFAULT_ALLOWED_TYPES,
|
||||
} from "../attachment-types.js";
|
||||
|
||||
describe("parseAllowedTypes", () => {
|
||||
it("returns default image types when input is undefined", () => {
|
||||
expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]);
|
||||
});
|
||||
|
||||
it("returns default image types when input is empty string", () => {
|
||||
expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]);
|
||||
});
|
||||
|
||||
it("parses comma-separated types", () => {
|
||||
expect(parseAllowedTypes("image/*,application/pdf")).toEqual([
|
||||
"image/*",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([
|
||||
"image/png",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lowercases entries", () => {
|
||||
expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]);
|
||||
});
|
||||
|
||||
it("filters empty segments", () => {
|
||||
expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([
|
||||
"image/png",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesContentType", () => {
|
||||
it("matches exact types", () => {
|
||||
const patterns = ["application/pdf", "image/png"];
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/plain", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches /* wildcard patterns", () => {
|
||||
const patterns = ["image/*"];
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/jpeg", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/svg+xml", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches .* wildcard patterns", () => {
|
||||
const patterns = ["application/vnd.openxmlformats-officedocument.*"];
|
||||
expect(
|
||||
matchesContentType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
patterns,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesContentType(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
patterns,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const patterns = ["application/pdf"];
|
||||
expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true);
|
||||
expect(matchesContentType("Application/Pdf", patterns)).toBe(true);
|
||||
});
|
||||
|
||||
it("combines exact and wildcard patterns", () => {
|
||||
const patterns = ["image/*", "application/pdf", "text/*"];
|
||||
expect(matchesContentType("image/webp", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/csv", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/zip", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles plain * as allow-all wildcard", () => {
|
||||
const patterns = ["*"];
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/plain", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
@ -60,9 +84,44 @@ describe("boardMutationGuard", () => {
|
|||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("does not block authenticated agent mutations", async () => {
|
||||
const app = createApp("agent");
|
||||
const res = await request(app).post("/mutate").send({ ok: true });
|
||||
it("allows board mutations when x-forwarded-host matches origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Host", "127.0.0.1")
|
||||
.set("X-Forwarded-Host", "10.90.10.20:3443")
|
||||
.set("Origin", "https://10.90.10.20:3443")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("blocks board mutations when x-forwarded-host does not match origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Host", "127.0.0.1")
|
||||
.set("X-Forwarded-Host", "10.90.10.20:3443")
|
||||
.set("Origin", "https://evil.example.com")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("does not block authenticated agent mutations", async () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
311
server/src/__tests__/budgets-service.test.ts
Normal file
311
server/src/__tests__/budgets-service.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { budgetService } from "../services/budgets.ts";
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
type SelectResult = unknown[];
|
||||
|
||||
function createDbStub(selectResults: SelectResult[]) {
|
||||
const pendingSelects = [...selectResults];
|
||||
const selectWhere = vi.fn(async () => pendingSelects.shift() ?? []);
|
||||
const selectThen = vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pendingSelects.shift() ?? [])));
|
||||
const selectOrderBy = vi.fn(async () => pendingSelects.shift() ?? []);
|
||||
const selectFrom = vi.fn(() => ({
|
||||
where: selectWhere,
|
||||
then: selectThen,
|
||||
orderBy: selectOrderBy,
|
||||
}));
|
||||
const select = vi.fn(() => ({
|
||||
from: selectFrom,
|
||||
}));
|
||||
|
||||
const insertValues = vi.fn();
|
||||
const insertReturning = vi.fn(async () => pendingInserts.shift() ?? []);
|
||||
const insert = vi.fn(() => ({
|
||||
values: insertValues.mockImplementation(() => ({
|
||||
returning: insertReturning,
|
||||
})),
|
||||
}));
|
||||
|
||||
const updateSet = vi.fn();
|
||||
const updateWhere = vi.fn(async () => pendingUpdates.shift() ?? []);
|
||||
const update = vi.fn(() => ({
|
||||
set: updateSet.mockImplementation(() => ({
|
||||
where: updateWhere,
|
||||
})),
|
||||
}));
|
||||
|
||||
const pendingInserts: unknown[][] = [];
|
||||
const pendingUpdates: unknown[][] = [];
|
||||
|
||||
return {
|
||||
db: {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
},
|
||||
queueInsert: (rows: unknown[]) => {
|
||||
pendingInserts.push(rows);
|
||||
},
|
||||
queueUpdate: (rows: unknown[] = []) => {
|
||||
pendingUpdates.push(rows);
|
||||
},
|
||||
selectWhere,
|
||||
insertValues,
|
||||
updateSet,
|
||||
};
|
||||
}
|
||||
|
||||
describe("budgetService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a hard-stop incident and pauses an agent when spend exceeds a budget", async () => {
|
||||
const policy = {
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const dbStub = createDbStub([
|
||||
[policy],
|
||||
[{ total: 150 }],
|
||||
[],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
status: "running",
|
||||
pauseReason: null,
|
||||
}],
|
||||
]);
|
||||
|
||||
dbStub.queueInsert([{
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
status: "pending",
|
||||
}]);
|
||||
dbStub.queueInsert([{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
approvalId: "approval-1",
|
||||
}]);
|
||||
dbStub.queueUpdate([]);
|
||||
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const service = budgetService(dbStub.db as any, { cancelWorkForScope });
|
||||
await service.evaluateCostEvent({
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
projectId: null,
|
||||
} as any);
|
||||
|
||||
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
type: "budget_override_required",
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
expect(dbStub.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 150,
|
||||
approvalId: "approval-1",
|
||||
}),
|
||||
);
|
||||
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "budget.hard_threshold_crossed",
|
||||
entityId: "incident-1",
|
||||
}),
|
||||
);
|
||||
expect(cancelWorkForScope).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks new work when an agent hard-stop remains exceeded even if the agent is not paused yet", async () => {
|
||||
const agentPolicy = {
|
||||
id: "policy-agent-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
status: "running",
|
||||
pauseReason: null,
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
}],
|
||||
[{
|
||||
status: "active",
|
||||
name: "Paperclip",
|
||||
}],
|
||||
[],
|
||||
[agentPolicy],
|
||||
[{ total: 120 }],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
const block = await service.getInvocationBlock("company-1", "agent-1");
|
||||
|
||||
expect(block).toEqual({
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
scopeName: "Budget Agent",
|
||||
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a budget-owned company pause distinctly from a manual pause", async () => {
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
}],
|
||||
[{
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
name: "Paperclip",
|
||||
}],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
const block = await service.getInvocationBlock("company-1", "agent-1");
|
||||
|
||||
expect(block).toEqual({
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
scopeName: "Paperclip",
|
||||
reason: "Company is paused because its budget hard-stop was reached.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses live observed spend when raising a budget incident", async () => {
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
amountObserved: 120,
|
||||
approvalId: "approval-1",
|
||||
}],
|
||||
[{
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
}],
|
||||
[{ total: 150 }],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
|
||||
await expect(
|
||||
service.resolveIncident(
|
||||
"company-1",
|
||||
"incident-1",
|
||||
{ action: "raise_budget_and_resume", amount: 140 },
|
||||
"board-user",
|
||||
),
|
||||
).rejects.toThrow("New budget must exceed current observed spend");
|
||||
});
|
||||
|
||||
it("syncs company monthly budget when raising and resuming a company incident", async () => {
|
||||
const now = new Date();
|
||||
const dbStub = createDbStub([
|
||||
[{
|
||||
id: "incident-1",
|
||||
companyId: "company-1",
|
||||
policyId: "policy-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
windowStart: now,
|
||||
windowEnd: now,
|
||||
thresholdType: "hard",
|
||||
amountLimit: 100,
|
||||
amountObserved: 120,
|
||||
status: "open",
|
||||
approvalId: "approval-1",
|
||||
resolvedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
[{
|
||||
id: "policy-1",
|
||||
companyId: "company-1",
|
||||
scopeType: "company",
|
||||
scopeId: "company-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 100,
|
||||
}],
|
||||
[{ total: 120 }],
|
||||
[{ id: "approval-1", status: "approved" }],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
name: "Paperclip",
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
}],
|
||||
]);
|
||||
|
||||
const service = budgetService(dbStub.db as any);
|
||||
await service.resolveIncident(
|
||||
"company-1",
|
||||
"incident-1",
|
||||
{ action: "raise_budget_and_resume", amount: 175 },
|
||||
"board-user",
|
||||
);
|
||||
|
||||
expect(dbStub.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
budgetMonthlyCents: 175,
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server";
|
||||
import { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
|
||||
describe("claude_local max-turn detection", () => {
|
||||
it("detects max-turn exhaustion by subtype", () => {
|
||||
|
|
@ -28,3 +30,158 @@ describe("claude_local max-turn detection", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claude_local ui stdout parser", () => {
|
||||
it("maps assistant text, thinking, tool calls, and tool results into transcript entries", () => {
|
||||
const ts = "2026-03-29T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseClaudeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
model: "claude-sonnet-4-6",
|
||||
session_id: "claude-session-1",
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: "claude-sonnet-4-6",
|
||||
sessionId: "claude-session-1",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseClaudeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
session_id: "claude-session-1",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "I will inspect the repo." },
|
||||
{ type: "thinking", thinking: "Checking the adapter wiring" },
|
||||
{ type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "assistant", ts, text: "I will inspect the repo." },
|
||||
{ kind: "thinking", ts, text: "Checking the adapter wiring" },
|
||||
{ kind: "tool_call", ts, name: "bash", toolUseId: "tool_1", input: { command: "ls -1" } },
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseClaudeStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [{ type: "text", text: "AGENTS.md\nREADME.md" }],
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "tool_1",
|
||||
content: "AGENTS.md\nREADME.md",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string) {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("claude_local cli formatter", () => {
|
||||
it("prints the user-visible and background transcript events from stream-json output", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printClaudeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
model: "claude-sonnet-4-6",
|
||||
session_id: "claude-session-1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printClaudeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "I will inspect the repo." },
|
||||
{ type: "thinking", thinking: "Checking the adapter wiring" },
|
||||
{ type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printClaudeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [{ type: "text", text: "AGENTS.md\nREADME.md" }],
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printClaudeStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Done",
|
||||
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 },
|
||||
total_cost_usd: 0.00042,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
const lines = spy.mock.calls
|
||||
.map((call) => call.map((value) => String(value)).join(" "))
|
||||
.map(stripAnsi);
|
||||
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Claude initialized (model: claude-sonnet-4-6, session: claude-session-1)",
|
||||
"assistant: I will inspect the repo.",
|
||||
"thinking: Checking the adapter wiring",
|
||||
"tool_call: bash",
|
||||
'{\n "command": "ls -1"\n}',
|
||||
"tool_result",
|
||||
"AGENTS.md\nREADME.md",
|
||||
"result:",
|
||||
"Done",
|
||||
"tokens: in=10 out=5 cached=2 cost=$0.000420",
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
99
server/src/__tests__/claude-local-execute.test.ts
Normal file
99
server/src/__tests__/claude-local-execute.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }));
|
||||
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }));
|
||||
console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
describe("claude execute", () => {
|
||||
it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "claude");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const claudeConfigDir = path.join(root, "claude-config");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(claudeConfigDir, { recursive: true });
|
||||
await writeFakeClaudeCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
|
||||
|
||||
let loggedCommand: string | null = null;
|
||||
let loggedEnv: Record<string, string> = {};
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-meta",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Claude Coder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "claude",
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
loggedCommand = meta.command;
|
||||
loggedEnv = meta.env ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(loggedCommand).toBe(commandPath);
|
||||
expect(loggedEnv.HOME).toBe(root);
|
||||
expect(loggedEnv.CLAUDE_CONFIG_DIR).toBe(claudeConfigDir);
|
||||
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
110
server/src/__tests__/claude-local-skill-sync.test.ts
Normal file
110
server/src/__tests__/claude-local-skill-sync.test.ts
Normal 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.",
|
||||
}));
|
||||
});
|
||||
});
|
||||
230
server/src/__tests__/cli-auth-routes.test.ts
Normal file
230
server/src/__tests__/cli-auth-routes.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
const itWindows = process.platform === "win32" ? it : it.skip;
|
||||
|
||||
describe("codex_local environment diagnostics", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
|
|
@ -29,4 +37,106 @@ describe("codex_local environment diagnostics", () => {
|
|||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("emits codex_native_auth_present when ~/.codex/auth.json exists and OPENAI_API_KEY is unset", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-codex-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const codexHome = path.join(root, ".codex");
|
||||
const cwd = path.join(root, "workspace");
|
||||
|
||||
try {
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexHome, "auth.json"),
|
||||
JSON.stringify({ accessToken: "fake-token", accountId: "acct-1" }),
|
||||
);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "codex_native_auth_present")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "codex_openai_api_key_missing")).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits codex_openai_api_key_missing when neither env var nor native auth exists", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-codex-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const codexHome = path.join(root, ".codex");
|
||||
const cwd = path.join(root, "workspace");
|
||||
|
||||
try {
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
// No auth.json written
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "codex_openai_api_key_missing")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "codex_native_auth_present")).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-codex-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
const fakeCodex = path.join(binDir, "codex.cmd");
|
||||
const script = [
|
||||
"@echo off",
|
||||
"echo {\"type\":\"thread.started\",\"thread_id\":\"test-thread\"}",
|
||||
"echo {\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"hello\"}}",
|
||||
"echo {\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1,\"cached_input_tokens\":0,\"output_tokens\":1}}",
|
||||
"exit /b 0",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
try {
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(fakeCodex, script, "utf8");
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
command: "codex",
|
||||
cwd,
|
||||
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 === "codex_hello_probe_passed")).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ describe("codex_local ui stdout parser", () => {
|
|||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
toolUseId: "item_2",
|
||||
input: { id: "item_2", command: "/bin/zsh -lc ls" },
|
||||
},
|
||||
]);
|
||||
|
|
@ -106,7 +107,7 @@ describe("codex_local ui stdout parser", () => {
|
|||
item: {
|
||||
id: "item_52",
|
||||
type: "file_change",
|
||||
changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
|
||||
changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
|
|
@ -116,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
|
|||
{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: "file changes: update /home/user/project/ui/src/pages/AgentDetail.tsx",
|
||||
text: "file changes: update /Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
454
server/src/__tests__/codex-local-execute.test.ts
Normal file
454
server/src/__tests__/codex-local-execute.test.ts
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
async function writeFakeCodexCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
codexHome: process.env.CODEX_HOME || null,
|
||||
paperclipEnvKeys: Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort(),
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({ type: "thread.started", thread_id: "codex-session-1" }));
|
||||
console.log(JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } }));
|
||||
console.log(JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
type CapturePayload = {
|
||||
argv: string[];
|
||||
prompt: string;
|
||||
codexHome: string | null;
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
stream: "stdout" | "stderr";
|
||||
chunk: string;
|
||||
};
|
||||
|
||||
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("logs HOME and the resolved executable path in invocation metadata", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-meta-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const binDir = path.join(root, "bin");
|
||||
const commandPath = path.join(binDir, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
|
||||
let loggedCommand: string | null = null;
|
||||
let loggedEnv: Record<string, string> = {};
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-meta",
|
||||
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: "codex",
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
loggedCommand = meta.command;
|
||||
loggedEnv = meta.env ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(loggedCommand).toBe(commandPath);
|
||||
expect(loggedEnv.HOME).toBe(root);
|
||||
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
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");
|
||||
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 isolatedCodexHome = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"worktree-1",
|
||||
"companies",
|
||||
"company-1",
|
||||
"codex-home",
|
||||
);
|
||||
const homeSkill = path.join(isolatedCodexHome, "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");
|
||||
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;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "worktree-1";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.CODEX_HOME = sharedCodexHome;
|
||||
|
||||
try {
|
||||
const logs: LogEntry[] = [];
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
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(isolatedCodexHome);
|
||||
expect(capture.argv).toEqual(expect.arrayContaining(["exec", "--json", "-"]));
|
||||
expect(capture.prompt).toContain("Follow the paperclip heartbeat.");
|
||||
expect(capture.paperclipEnvKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"PAPERCLIP_AGENT_ID",
|
||||
"PAPERCLIP_API_KEY",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_COMPANY_ID",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
]),
|
||||
);
|
||||
|
||||
const isolatedAuth = path.join(isolatedCodexHome, "auth.json");
|
||||
const isolatedConfig = path.join(isolatedCodexHome, "config.toml");
|
||||
|
||||
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(homeSkill)).isSymbolicLink()).toBe(true);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "stdout",
|
||||
chunk: expect.stringContaining("Using worktree-isolated Codex home"),
|
||||
}),
|
||||
);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "stdout",
|
||||
chunk: expect.stringContaining('Injected Codex skill "paperclip"'),
|
||||
}),
|
||||
);
|
||||
} 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("respects an explicit CODEX_HOME config override even in worktree mode", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-explicit-"));
|
||||
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 explicitCodexHome = path.join(root, "explicit-codex-home");
|
||||
const paperclipHome = path.join(root, "paperclip-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 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;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "worktree-1";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.CODEX_HOME = sharedCodexHome;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-2",
|
||||
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,
|
||||
CODEX_HOME: explicitCodexHome,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.codexHome).toBe(explicitCodexHome);
|
||||
expect((await fs.lstat(path.join(explicitCodexHome, "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;
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
174
server/src/__tests__/codex-local-skill-injection.test.ts
Normal file
174
server/src/__tests__/codex-local-skill-injection.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ensureCodexSkillsInjected } from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function createPaperclipRepoSkill(root: string, skillName: string) {
|
||||
await fs.mkdir(path.join(root, "server"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "packages", "adapter-utils"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "skills", skillName), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n", "utf8");
|
||||
await fs.writeFile(path.join(root, "package.json"), '{"name":"paperclip"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(root, "skills", skillName, "SKILL.md"),
|
||||
`---\nname: ${skillName}\n---\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function createCustomSkill(root: string, skillName: string) {
|
||||
await fs.mkdir(path.join(root, "custom", skillName), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "custom", skillName, "SKILL.md"),
|
||||
`---\nname: ${skillName}\n---\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("codex local adapter skill injection", () => {
|
||||
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("repairs a Codex Paperclip skill symlink that still points at another live checkout", 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, "paperclip");
|
||||
await fs.symlink(path.join(oldRepo, "skills", "paperclip"), path.join(skillsHome, "paperclip"));
|
||||
|
||||
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"),
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
||||
await fs.realpath(path.join(currentRepo, "skills", "paperclip")),
|
||||
);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stream: "stdout",
|
||||
chunk: expect.stringContaining('Repaired Codex skill "paperclip"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {
|
||||
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||
const customRoot = await makeTempDir("paperclip-codex-custom-");
|
||||
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||
cleanupDirs.add(currentRepo);
|
||||
cleanupDirs.add(customRoot);
|
||||
cleanupDirs.add(skillsHome);
|
||||
|
||||
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||
await createCustomSkill(customRoot, "paperclip");
|
||||
await fs.symlink(path.join(customRoot, "custom", "paperclip"), path.join(skillsHome, "paperclip"));
|
||||
|
||||
await ensureCodexSkillsInjected(async () => {}, {
|
||||
skillsHome,
|
||||
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")),
|
||||
);
|
||||
});
|
||||
});
|
||||
122
server/src/__tests__/codex-local-skill-sync.test.ts
Normal file
122
server/src/__tests__/codex-local-skill-sync.test.ts
Normal 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("CODEX_HOME/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();
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ vi.mock("../services/index.js", () => ({
|
|||
}),
|
||||
companyPortabilityService: () => ({
|
||||
exportBundle: vi.fn(),
|
||||
previewExport: vi.fn(),
|
||||
previewImport: vi.fn(),
|
||||
importBundle: vi.fn(),
|
||||
}),
|
||||
|
|
@ -22,6 +23,12 @@ vi.mock("../services/index.js", () => ({
|
|||
canUser: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
}),
|
||||
budgetService: () => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
196
server/src/__tests__/company-branding-route.test.ts
Normal file
196
server/src/__tests__/company-branding-route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
175
server/src/__tests__/company-portability-routes.test.ts
Normal file
175
server/src/__tests__/company-portability-routes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
2185
server/src/__tests__/company-portability.test.ts
Normal file
2185
server/src/__tests__/company-portability.test.ts
Normal file
File diff suppressed because it is too large
Load diff
113
server/src/__tests__/company-skills-routes.test.ts
Normal file
113
server/src/__tests__/company-skills-routes.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
229
server/src/__tests__/company-skills.test.ts
Normal file
229
server/src/__tests__/company-skills.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
226
server/src/__tests__/costs-service.test.ts
Normal file
226
server/src/__tests__/costs-service.test.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { costRoutes } from "../routes/costs.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
function makeDb(overrides: Record<string, unknown> = {}) {
|
||||
const selectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
leftJoin: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
groupBy: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const thenableChain = Object.assign(Promise.resolve([]), selectChain);
|
||||
|
||||
return {
|
||||
select: vi.fn().mockReturnValue(thenableChain),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
cancelBudgetScopeWork: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockFetchAllQuotaWindows = vi.hoisted(() => vi.fn());
|
||||
const mockCostService = vi.hoisted(() => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
|
||||
byAgent: vi.fn().mockResolvedValue([]),
|
||||
byAgentModel: vi.fn().mockResolvedValue([]),
|
||||
byProvider: vi.fn().mockResolvedValue([]),
|
||||
byBiller: vi.fn().mockResolvedValue([]),
|
||||
windowSpend: vi.fn().mockResolvedValue([]),
|
||||
byProject: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
const mockFinanceService = vi.hoisted(() => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0 }),
|
||||
byBiller: vi.fn().mockResolvedValue([]),
|
||||
byKind: vi.fn().mockResolvedValue([]),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
overview: vi.fn().mockResolvedValue({
|
||||
companyId: "company-1",
|
||||
policies: [],
|
||||
activeIncidents: [],
|
||||
pausedAgentCount: 0,
|
||||
pausedProjectCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
}),
|
||||
upsertPolicy: vi.fn(),
|
||||
resolveIncident: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
budgetService: () => mockBudgetService,
|
||||
costService: () => mockCostService,
|
||||
financeService: () => mockFinanceService,
|
||||
companyService: () => mockCompanyService,
|
||||
agentService: () => mockAgentService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
vi.mock("../services/quota-windows.js", () => ({
|
||||
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", userId: "board-user", source: "local_implicit" };
|
||||
next();
|
||||
});
|
||||
app.use("/api", costRoutes(makeDb() as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function createAppWithActor(actor: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", costRoutes(makeDb() as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCompanyService.update.mockResolvedValue({
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
budgetMonthlyCents: 100,
|
||||
spentMonthlyCents: 0,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
budgetMonthlyCents: 100,
|
||||
spentMonthlyCents: 0,
|
||||
});
|
||||
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("cost routes", () => {
|
||||
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ from: "2026-01-01T00:00:00.000Z", to: "2026-01-31T23:59:59.999Z" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid 'from' date string", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ from: "not-a-date" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'from' date/i);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid 'to' date string", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
.query({ to: "banana" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'to' date/i);
|
||||
});
|
||||
|
||||
it("returns finance summary rows for valid requests", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-summary")
|
||||
.query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFinanceService.summary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid finance event list limits", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "0" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'limit'/i);
|
||||
});
|
||||
|
||||
it("accepts valid finance event list limits", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "25" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25);
|
||||
});
|
||||
|
||||
it("rejects company budget updates for board users outside the company", async () => {
|
||||
const app = createAppWithActor({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-2"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/companies/company-1/budgets")
|
||||
.send({ budgetMonthlyCents: 2500 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockCompanyService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent budget updates for board users outside the agent company", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
budgetMonthlyCents: 100,
|
||||
spentMonthlyCents: 0,
|
||||
});
|
||||
const app = createAppWithActor({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-2"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/agents/agent-1/budgets")
|
||||
.send({ budgetMonthlyCents: 2500 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
|
@ -28,6 +28,13 @@ console.log(JSON.stringify({
|
|||
}
|
||||
|
||||
describe("cursor environment diagnostics", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CURSOR_API_KEY", "");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
|
|
@ -116,4 +123,73 @@ describe("cursor environment diagnostics", () => {
|
|||
expect(args).not.toContain("--trust");
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("emits cursor_native_auth_present when cli-config.json has authInfo and CURSOR_API_KEY is unset", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-cursor-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const cursorHome = path.join(root, ".cursor");
|
||||
const cwd = path.join(root, "workspace");
|
||||
|
||||
try {
|
||||
await fs.mkdir(cursorHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(cursorHome, "cli-config.json"),
|
||||
JSON.stringify({
|
||||
authInfo: {
|
||||
email: "test@example.com",
|
||||
displayName: "Test User",
|
||||
userId: 12345,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
env: { CURSOR_HOME: cursorHome },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "cursor_native_auth_present")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "cursor_api_key_missing")).toBe(false);
|
||||
const authCheck = result.checks.find((check) => check.code === "cursor_native_auth_present");
|
||||
expect(authCheck?.detail).toContain("test@example.com");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits cursor_api_key_missing when neither env var nor native auth exists", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-cursor-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const cursorHome = path.join(root, ".cursor");
|
||||
const cwd = path.join(root, "workspace");
|
||||
|
||||
try {
|
||||
await fs.mkdir(cursorHome, { recursive: true });
|
||||
// No cli-config.json written
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
env: { CURSOR_HOME: cursorHome },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "cursor_api_key_missing")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "cursor_native_auth_present")).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ describe("cursor ui stdout parser", () => {
|
|||
kind: "tool_call",
|
||||
ts,
|
||||
name: "shellToolCall",
|
||||
toolUseId: "call_shell_1",
|
||||
input: { command: longCommand },
|
||||
},
|
||||
]);
|
||||
|
|
@ -254,7 +255,7 @@ describe("cursor ui stdout parser", () => {
|
|||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", input: { path: "README.md" } }]);
|
||||
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]);
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
144
server/src/__tests__/cursor-local-skill-sync.test.ts
Normal file
144
server/src/__tests__/cursor-local-skill-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
25
server/src/__tests__/dev-runner-paths.test.ts
Normal file
25
server/src/__tests__/dev-runner-paths.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
66
server/src/__tests__/dev-server-status.test.ts
Normal file
66
server/src/__tests__/dev-server-status.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
42
server/src/__tests__/dev-watch-ignore.test.ts
Normal file
42
server/src/__tests__/dev-watch-ignore.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveServerDevWatchIgnorePaths } from "../dev-watch-ignore.js";
|
||||
|
||||
describe("resolveServerDevWatchIgnorePaths", () => {
|
||||
it("includes both the worktree UI paths and their real shared targets", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-watch-"));
|
||||
const sharedUiRoot = path.join(tempRoot, "shared-ui");
|
||||
const worktreeRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees", "PAP-884");
|
||||
const serverRoot = path.join(worktreeRoot, "server");
|
||||
const worktreeUiRoot = path.join(worktreeRoot, "ui");
|
||||
|
||||
fs.mkdirSync(path.join(sharedUiRoot, "node_modules"), { recursive: true });
|
||||
fs.mkdirSync(path.join(sharedUiRoot, ".vite"), { recursive: true });
|
||||
fs.mkdirSync(path.join(sharedUiRoot, "dist"), { recursive: true });
|
||||
fs.mkdirSync(serverRoot, { recursive: true });
|
||||
fs.mkdirSync(worktreeUiRoot, { recursive: true });
|
||||
|
||||
fs.symlinkSync(path.join(sharedUiRoot, "node_modules"), path.join(worktreeUiRoot, "node_modules"));
|
||||
fs.symlinkSync(path.join(sharedUiRoot, ".vite"), path.join(worktreeUiRoot, ".vite"));
|
||||
fs.symlinkSync(path.join(sharedUiRoot, "dist"), path.join(worktreeUiRoot, "dist"));
|
||||
|
||||
const ignorePaths = resolveServerDevWatchIgnorePaths(serverRoot);
|
||||
|
||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules"));
|
||||
expect(ignorePaths).toContain(`${path.join(worktreeUiRoot, "node_modules").replaceAll(path.sep, "/")}/**`);
|
||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "node_modules")));
|
||||
expect(ignorePaths).toContain(`${fs.realpathSync(path.join(sharedUiRoot, "node_modules")).replaceAll(path.sep, "/")}/**`);
|
||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules", ".vite-temp"));
|
||||
expect(ignorePaths).toContain(
|
||||
`${path.join(worktreeUiRoot, "node_modules", ".vite-temp").replaceAll(path.sep, "/")}/**`,
|
||||
);
|
||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, ".vite"));
|
||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, ".vite")));
|
||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "dist"));
|
||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "dist")));
|
||||
expect(ignorePaths).toContain("**/{node_modules,bower_components,vendor}/**");
|
||||
expect(ignorePaths).toContain("**/.vite-temp/**");
|
||||
});
|
||||
});
|
||||
29
server/src/__tests__/documents.test.ts
Normal file
29
server/src/__tests__/documents.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { extractLegacyPlanBody } from "../services/documents.js";
|
||||
|
||||
describe("extractLegacyPlanBody", () => {
|
||||
it("returns null when no plan block exists", () => {
|
||||
expect(extractLegacyPlanBody("hello world")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts plan body from legacy issue descriptions", () => {
|
||||
expect(
|
||||
extractLegacyPlanBody(`
|
||||
intro
|
||||
|
||||
<plan>
|
||||
|
||||
# Plan
|
||||
|
||||
- one
|
||||
- two
|
||||
|
||||
</plan>
|
||||
`),
|
||||
).toBe("# Plan\n\n- one\n- two");
|
||||
});
|
||||
|
||||
it("ignores empty plan blocks", () => {
|
||||
expect(extractLegacyPlanBody("<plan> </plan>")).toBeNull();
|
||||
});
|
||||
});
|
||||
170
server/src/__tests__/execution-workspace-policy.test.ts
Normal file
170
server/src/__tests__/execution-workspace-policy.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
|
||||
describe("execution workspace policy helpers", () => {
|
||||
it("defaults new issue settings from enabled project policy", () => {
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
}),
|
||||
).toEqual({ mode: "isolated_workspace" });
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
}),
|
||||
).toEqual({ mode: "shared_workspace" });
|
||||
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers explicit issue mode over project policy and legacy overrides", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: { enabled: true, defaultMode: "shared_workspace" },
|
||||
issueSettings: { mode: "isolated_workspace" },
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("isolated_workspace");
|
||||
});
|
||||
|
||||
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
||||
issueSettings: null,
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("isolated_workspace");
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: null,
|
||||
issueSettings: null,
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("agent_default");
|
||||
});
|
||||
|
||||
it("applies project policy strategy and runtime defaults when isolation is enabled", () => {
|
||||
const result = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: {
|
||||
workspaceStrategy: { type: "project_primary" },
|
||||
},
|
||||
projectPolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
baseRef: "origin/main",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
issueSettings: null,
|
||||
mode: "isolated_workspace",
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
|
||||
expect(result.workspaceStrategy).toEqual({
|
||||
type: "git_worktree",
|
||||
baseRef: "origin/main",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
});
|
||||
expect(result.workspaceRuntime).toEqual({
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears managed workspace strategy when issue opts out to project primary or agent default", () => {
|
||||
const baseConfig = {
|
||||
workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" },
|
||||
workspaceRuntime: { services: [{ name: "web" }] },
|
||||
};
|
||||
|
||||
expect(
|
||||
buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: baseConfig,
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
||||
issueSettings: { mode: "shared_workspace" },
|
||||
mode: "shared_workspace",
|
||||
legacyUseProjectWorkspace: null,
|
||||
}).workspaceStrategy,
|
||||
).toBeUndefined();
|
||||
|
||||
const agentDefault = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: baseConfig,
|
||||
projectPolicy: null,
|
||||
issueSettings: { mode: "agent_default" },
|
||||
mode: "agent_default",
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
expect(agentDefault.workspaceStrategy).toBeUndefined();
|
||||
expect(agentDefault.workspaceRuntime).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses persisted JSON payloads into typed project and issue workspace settings", () => {
|
||||
expect(
|
||||
parseProjectExecutionWorkspacePolicy({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseIssueExecutionWorkspaceSettings({
|
||||
mode: "project_primary",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
{ enabled: true, defaultMode: "isolated_workspace" },
|
||||
false,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
{ enabled: true, defaultMode: "isolated_workspace" },
|
||||
true,
|
||||
),
|
||||
).toEqual({ enabled: true, defaultMode: "isolated_workspace" });
|
||||
});
|
||||
});
|
||||
325
server/src/__tests__/execution-workspaces-service.test.ts
Normal file
325
server/src/__tests__/execution-workspaces-service.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
executionWorkspaceService,
|
||||
mergeExecutionWorkspaceConfig,
|
||||
readExecutionWorkspaceConfig,
|
||||
} from "../services/execution-workspaces.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe("execution workspace config helpers", () => {
|
||||
it("reads typed config from persisted metadata", () => {
|
||||
expect(readExecutionWorkspaceConfig({
|
||||
source: "project_primary",
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges config patches without dropping unrelated metadata", () => {
|
||||
expect(mergeExecutionWorkspaceConfig(
|
||||
{
|
||||
source: "project_primary",
|
||||
createdByRuntime: false,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
},
|
||||
},
|
||||
{
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
)).toEqual({
|
||||
source: "project_primary",
|
||||
createdByRuntime: false,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
cleanupCommand: "pkill -f vite || true",
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the nested config block when requested", () => {
|
||||
expect(mergeExecutionWorkspaceConfig(
|
||||
{
|
||||
source: "project_primary",
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
},
|
||||
null,
|
||||
)).toEqual({
|
||||
source: "project_primary",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres execution workspace service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-execution-workspace-"));
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
|
||||
await fs.writeFile(path.join(repoRoot, "README.md"), "# Test repo\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "README.md"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||
await runGit(repoRoot, ["branch", "-M", "main"]);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof executionWorkspaceService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = executionWorkspaceService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
|
||||
for (const dir of tempDirs) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("allows archiving shared workspace sessions with warnings even when issues are still open", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "local_path",
|
||||
isPrimary: true,
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Shared workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
metadata: {
|
||||
config: {
|
||||
teardownCommand: "bash ./scripts/teardown.sh",
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Still working",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
|
||||
|
||||
expect(readiness).toMatchObject({
|
||||
workspaceId: executionWorkspaceId,
|
||||
state: "ready_with_warnings",
|
||||
isSharedWorkspace: true,
|
||||
isProjectPrimaryWorkspace: true,
|
||||
isDestructiveCloseAllowed: true,
|
||||
});
|
||||
expect(readiness?.blockingReasons).toEqual([]);
|
||||
expect(readiness?.warnings).toEqual(expect.arrayContaining([
|
||||
"This workspace is still linked to an open issue. Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.",
|
||||
"This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.",
|
||||
]));
|
||||
});
|
||||
|
||||
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
tempDirs.add(repoRoot);
|
||||
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-worktree-${randomUUID()}`);
|
||||
tempDirs.add(worktreePath);
|
||||
|
||||
await runGit(repoRoot, ["branch", "paperclip-close-check"]);
|
||||
await runGit(repoRoot, ["worktree", "add", worktreePath, "paperclip-close-check"]);
|
||||
await fs.writeFile(path.join(worktreePath, "feature.txt"), "hello\n", "utf8");
|
||||
await runGit(worktreePath, ["add", "feature.txt"]);
|
||||
await runGit(worktreePath, ["commit", "-m", "Feature commit"]);
|
||||
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "left behind\n", "utf8");
|
||||
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
teardownCommand: "bash ./scripts/project-teardown.sh",
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "git_repo",
|
||||
isPrimary: true,
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: "printf 'project cleanup\\n'",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Feature workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "paperclip-close-check",
|
||||
baseRef: "main",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
config: {
|
||||
cleanupCommand: "printf 'workspace cleanup\\n'",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
|
||||
|
||||
expect(readiness).toMatchObject({
|
||||
workspaceId: executionWorkspaceId,
|
||||
state: "ready_with_warnings",
|
||||
isSharedWorkspace: false,
|
||||
isProjectPrimaryWorkspace: false,
|
||||
isDestructiveCloseAllowed: true,
|
||||
git: {
|
||||
workspacePath: worktreePath,
|
||||
branchName: "paperclip-close-check",
|
||||
baseRef: "main",
|
||||
createdByRuntime: true,
|
||||
hasDirtyTrackedFiles: false,
|
||||
hasUntrackedFiles: true,
|
||||
aheadCount: 1,
|
||||
behindCount: 0,
|
||||
isMergedIntoBase: false,
|
||||
},
|
||||
});
|
||||
expect(readiness?.warnings).toEqual(expect.arrayContaining([
|
||||
"The workspace has 1 untracked file.",
|
||||
"This workspace is 1 commit ahead of main and is not merged.",
|
||||
]));
|
||||
expect(readiness?.plannedActions.map((action) => action.kind)).toEqual(expect.arrayContaining([
|
||||
"archive_record",
|
||||
"cleanup_command",
|
||||
"teardown_command",
|
||||
"git_worktree_remove",
|
||||
"git_branch_delete",
|
||||
]));
|
||||
}, 20_000);
|
||||
});
|
||||
77
server/src/__tests__/forbidden-tokens.test.ts
Normal file
77
server/src/__tests__/forbidden-tokens.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
resolveDynamicForbiddenTokens,
|
||||
resolveForbiddenTokens,
|
||||
runForbiddenTokenCheck,
|
||||
} = await import("../../../scripts/check-forbidden-tokens.mjs");
|
||||
|
||||
describe("forbidden token check", () => {
|
||||
it("derives username tokens without relying on whoami", () => {
|
||||
const tokens = resolveDynamicForbiddenTokens(
|
||||
{ USER: "paperclip", LOGNAME: "paperclip", USERNAME: "pc" },
|
||||
{
|
||||
userInfo: () => ({ username: "paperclip" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(tokens).toEqual(["paperclip", "pc"]);
|
||||
});
|
||||
|
||||
it("falls back cleanly when user resolution fails", () => {
|
||||
const tokens = resolveDynamicForbiddenTokens(
|
||||
{},
|
||||
{
|
||||
userInfo: () => {
|
||||
throw new Error("missing user");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges dynamic and file-based forbidden tokens", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
|
||||
const tokensFile = path.join(os.tmpdir(), `forbidden-tokens-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tokensFile, "# comment\npaperclip\ncustom-token\n");
|
||||
|
||||
try {
|
||||
const tokens = resolveForbiddenTokens(tokensFile, { USER: "paperclip" }, {
|
||||
userInfo: () => ({ username: "paperclip" }),
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(["paperclip", "custom-token"]);
|
||||
} finally {
|
||||
fs.unlinkSync(tokensFile);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports matches without leaking which token was searched", () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("server/file.ts:1:found\n")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("not found");
|
||||
});
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
|
||||
const exitCode = runForbiddenTokenCheck({
|
||||
repoRoot: "/repo",
|
||||
tokens: ["paperclip", "custom-token"],
|
||||
exec,
|
||||
log,
|
||||
error,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(exec).toHaveBeenCalledTimes(2);
|
||||
expect(error).toHaveBeenCalledWith("ERROR: Forbidden tokens found in tracked files:\n");
|
||||
expect(error).toHaveBeenCalledWith(" server/file.ts:1:found");
|
||||
expect(error).toHaveBeenCalledWith("\nBuild blocked. Remove the forbidden token(s) before publishing.");
|
||||
});
|
||||
});
|
||||
134
server/src/__tests__/gemini-local-adapter-environment.test.ts
Normal file
134
server/src/__tests__/gemini-local-adapter-environment.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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-gemini-local/server";
|
||||
|
||||
async function writeFakeGeminiCommand(binDir: string, argsCapturePath: string): Promise<string> {
|
||||
const commandPath = path.join(binDir, "gemini");
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH;
|
||||
if (outPath) {
|
||||
fs.writeFileSync(outPath, JSON.stringify(process.argv.slice(2)), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "hello",
|
||||
}));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
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(
|
||||
os.tmpdir(),
|
||||
`paperclip-gemini-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
"workspace",
|
||||
);
|
||||
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "gemini_cwd_valid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
const stats = await fs.stat(cwd);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("passes model and yolo flags to the hello probe", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-gemini-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
const argsCapturePath = path.join(root, "args.json");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeGeminiCommand(binDir, argsCapturePath);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
command: "gemini",
|
||||
cwd,
|
||||
model: "gemini-2.5-pro",
|
||||
yolo: true,
|
||||
env: {
|
||||
GEMINI_API_KEY: "test-key",
|
||||
PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe("fail");
|
||||
const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[];
|
||||
expect(args).toContain("--model");
|
||||
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 });
|
||||
});
|
||||
});
|
||||
189
server/src/__tests__/gemini-local-adapter.test.ts
Normal file
189
server/src/__tests__/gemini-local-adapter.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server";
|
||||
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
|
||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||
|
||||
describe("gemini_local parser", () => {
|
||||
it("extracts session, summary, usage, cost, and terminal error message", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "output_text", text: "hello" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "gemini-session-1",
|
||||
usage: {
|
||||
promptTokenCount: 12,
|
||||
cachedContentTokenCount: 3,
|
||||
candidatesTokenCount: 7,
|
||||
},
|
||||
total_cost_usd: 0.00123,
|
||||
result: "done",
|
||||
}),
|
||||
JSON.stringify({ type: "error", message: "model access denied" }),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("gemini-session-1");
|
||||
expect(parsed.summary).toBe("hello");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 12,
|
||||
cachedInputTokens: 3,
|
||||
outputTokens: 7,
|
||||
});
|
||||
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
|
||||
expect(parsed.errorMessage).toBe("model access denied");
|
||||
});
|
||||
|
||||
it("extracts structured questions", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "output_text", text: "I have a question." },
|
||||
{
|
||||
type: "question",
|
||||
prompt: "Which model?",
|
||||
choices: [
|
||||
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||
{ key: "flash", label: "Gemini Flash" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
expect(parsed.summary).toBe("I have a question.");
|
||||
expect(parsed.question).toEqual({
|
||||
prompt: "Which model?",
|
||||
choices: [
|
||||
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||
{ key: "flash", label: "Gemini Flash", description: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local stale session detection", () => {
|
||||
it("treats missing session messages as an unknown session error", () => {
|
||||
expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true);
|
||||
expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local ui stdout parser", () => {
|
||||
it("parses assistant, thinking, and result events", () => {
|
||||
const ts = "2026-03-08T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseGeminiStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "output_text", text: "I checked the repo." },
|
||||
{ type: "thinking", text: "Reviewing adapter registry" },
|
||||
{ type: "tool_call", name: "shell", input: { command: "ls -1" } },
|
||||
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "assistant", ts, text: "I checked the repo." },
|
||||
{ kind: "thinking", ts, text: "Reviewing adapter registry" },
|
||||
{ kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } },
|
||||
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseGeminiStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Done",
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
cachedContentTokenCount: 2,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
is_error: false,
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Done",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedTokens: 2,
|
||||
costUsd: 0.00042,
|
||||
subtype: "success",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("gemini_local cli formatter", () => {
|
||||
it("prints init, assistant, result, and error events", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let joined = "";
|
||||
|
||||
try {
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
cachedContentTokenCount: 2,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({ type: "error", message: "boom" }),
|
||||
false,
|
||||
);
|
||||
joined = spy.mock.calls.map((call) => stripAnsi(call.join(" "))).join("\n");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(joined).toContain("Gemini init");
|
||||
expect(joined).toContain("assistant: hello");
|
||||
expect(joined).toContain("tokens: in=10 out=5 cached=2 cost=$0.000420");
|
||||
expect(joined).toContain("error: boom");
|
||||
});
|
||||
});
|
||||
171
server/src/__tests__/gemini-local-execute.test.ts
Normal file
171
server/src/__tests__/gemini-local-execute.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-gemini-local/server";
|
||||
|
||||
async function writeFakeGeminiCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
paperclipEnvKeys: Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort(),
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "gemini-session-1",
|
||||
model: "gemini-2.5-pro",
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "gemini-session-1",
|
||||
result: "ok",
|
||||
}));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
type CapturePayload = {
|
||||
argv: string[];
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
describe("gemini execute", () => {
|
||||
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");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
let invocationPrompt = "";
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Gemini Coder",
|
||||
adapterType: "gemini_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "gemini-2.5-pro",
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
invocationPrompt = meta.prompt ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
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");
|
||||
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",
|
||||
"PAPERCLIP_API_KEY",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_COMPANY_ID",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
]),
|
||||
);
|
||||
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
||||
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
||||
expect(invocationPrompt).toContain("Paperclip API access note:");
|
||||
expect(invocationPrompt).toContain("run_shell_command");
|
||||
expect(result.question).toBeNull();
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("always passes --approval-mode yolo", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-yolo",
|
||||
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||
},
|
||||
context: {},
|
||||
authToken: "t",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--approval-mode");
|
||||
expect(capture.argv).toContain("yolo");
|
||||
expect(capture.argv).not.toContain("--policy");
|
||||
expect(capture.argv).not.toContain("--allow-all");
|
||||
expect(capture.argv).not.toContain("--allow-read");
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
89
server/src/__tests__/gemini-local-skill-sync.test.ts
Normal file
89
server/src/__tests__/gemini-local-skill-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,56 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
import * as devServerStatus from "../dev-server-status.js";
|
||||
import { serverVersion } from "../version.js";
|
||||
|
||||
describe("GET /health", () => {
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes());
|
||||
beforeEach(() => {
|
||||
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 200 with status ok", async () => {
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes());
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: "ok" });
|
||||
expect(res.body).toEqual({ status: "ok", version: serverVersion });
|
||||
});
|
||||
|
||||
it("returns 200 when the database probe succeeds", async () => {
|
||||
const db = {
|
||||
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
||||
} as unknown as Db;
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes(db));
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ status: "ok", version: serverVersion });
|
||||
});
|
||||
|
||||
it("returns 503 when the database probe fails", async () => {
|
||||
const db = {
|
||||
execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
|
||||
} as unknown as Db;
|
||||
const app = express();
|
||||
app.use("/health", healthRoutes(db));
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body).toEqual({
|
||||
status: "unhealthy",
|
||||
version: serverVersion,
|
||||
error: "database_unreachable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
255
server/src/__tests__/heartbeat-process-recovery.test.ts
Normal file
255
server/src/__tests__/heartbeat-process-recovery.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { runningProcesses } from "../adapters/index.ts";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres heartbeat recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function spawnAliveProcess() {
|
||||
return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const childProcesses = new Set<ChildProcess>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
runningProcesses.clear();
|
||||
for (const child of childProcesses) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
childProcesses.clear();
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const child of childProcesses) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
childProcesses.clear();
|
||||
runningProcesses.clear();
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedRunFixture(input?: {
|
||||
adapterType?: string;
|
||||
runStatus?: "running" | "queued" | "failed";
|
||||
processPid?: number | null;
|
||||
processLossRetryCount?: number;
|
||||
includeIssue?: boolean;
|
||||
runErrorCode?: string | null;
|
||||
runError?: string | null;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const wakeupRequestId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const now = new Date("2026-03-19T00:00:00.000Z");
|
||||
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: "paused",
|
||||
adapterType: input?.adapterType ?? "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
id: wakeupRequestId,
|
||||
companyId,
|
||||
agentId,
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: input?.includeIssue === false ? {} : { issueId },
|
||||
status: "claimed",
|
||||
runId,
|
||||
claimedAt: now,
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: input?.runStatus ?? "running",
|
||||
wakeupRequestId,
|
||||
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
|
||||
processPid: input?.processPid ?? null,
|
||||
processLossRetryCount: input?.processLossRetryCount ?? 0,
|
||||
errorCode: input?.runErrorCode ?? null,
|
||||
error: input?.runError ?? null,
|
||||
startedAt: now,
|
||||
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
if (input?.includeIssue !== false) {
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Recover local adapter after lost process",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
checkoutRunId: runId,
|
||||
executionRunId: runId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
}
|
||||
|
||||
return { companyId, agentId, runId, wakeupRequestId, issueId };
|
||||
}
|
||||
|
||||
it("keeps a local run active when the recorded pid is still alive", async () => {
|
||||
const child = spawnAliveProcess();
|
||||
childProcesses.add(child);
|
||||
expect(child.pid).toBeTypeOf("number");
|
||||
|
||||
const { runId, wakeupRequestId } = await seedRunFixture({
|
||||
processPid: child.pid ?? null,
|
||||
includeIssue: false,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reapOrphanedRuns();
|
||||
expect(result.reaped).toBe(0);
|
||||
|
||||
const run = await heartbeat.getRun(runId);
|
||||
expect(run?.status).toBe("running");
|
||||
expect(run?.errorCode).toBe("process_detached");
|
||||
expect(run?.error).toContain(String(child.pid));
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.status).toBe("claimed");
|
||||
});
|
||||
|
||||
it("queues exactly one retry when the recorded local pid is dead", async () => {
|
||||
const { agentId, runId, issueId } = await seedRunFixture({
|
||||
processPid: 999_999_999,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reapOrphanedRuns();
|
||||
expect(result.reaped).toBe(1);
|
||||
expect(result.runIds).toEqual([runId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
|
||||
const failedRun = runs.find((row) => row.id === runId);
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(failedRun?.status).toBe("failed");
|
||||
expect(failedRun?.errorCode).toBe("process_lost");
|
||||
expect(retryRun?.status).toBe("queued");
|
||||
expect(retryRun?.retryOfRunId).toBe(runId);
|
||||
expect(retryRun?.processLossRetryCount).toBe(1);
|
||||
|
||||
const issue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBe(retryRun?.id ?? null);
|
||||
expect(issue?.checkoutRunId).toBe(runId);
|
||||
});
|
||||
|
||||
it("does not queue a second retry after the first process-loss retry was already used", async () => {
|
||||
const { agentId, runId, issueId } = await seedRunFixture({
|
||||
processPid: 999_999_999,
|
||||
processLossRetryCount: 1,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reapOrphanedRuns();
|
||||
expect(result.reaped).toBe(1);
|
||||
expect(result.runIds).toEqual([runId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("failed");
|
||||
|
||||
const issue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
expect(issue?.checkoutRunId).toBe(runId);
|
||||
});
|
||||
|
||||
it("clears the detached warning when the run reports activity again", async () => {
|
||||
const { runId } = await seedRunFixture({
|
||||
includeIssue: false,
|
||||
runErrorCode: "process_detached",
|
||||
runError: "Lost in-memory process handle, but child pid 123 is still alive",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const updated = await heartbeat.reportRunActivity(runId);
|
||||
expect(updated?.errorCode).toBeNull();
|
||||
expect(updated?.error).toBeNull();
|
||||
|
||||
const run = await heartbeat.getRun(runId);
|
||||
expect(run?.errorCode).toBeNull();
|
||||
expect(run?.error).toBeNull();
|
||||
});
|
||||
});
|
||||
33
server/src/__tests__/heartbeat-run-summary.test.ts
Normal file
33
server/src/__tests__/heartbeat-run-summary.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js";
|
||||
|
||||
describe("summarizeHeartbeatRunResultJson", () => {
|
||||
it("truncates text fields and preserves cost aliases", () => {
|
||||
const summary = summarizeHeartbeatRunResultJson({
|
||||
summary: "a".repeat(600),
|
||||
result: "ok",
|
||||
message: "done",
|
||||
error: "failed",
|
||||
total_cost_usd: 1.23,
|
||||
cost_usd: 0.45,
|
||||
costUsd: 0.67,
|
||||
nested: { ignored: true },
|
||||
});
|
||||
|
||||
expect(summary).toEqual({
|
||||
summary: "a".repeat(500),
|
||||
result: "ok",
|
||||
message: "done",
|
||||
error: "failed",
|
||||
total_cost_usd: 1.23,
|
||||
cost_usd: 0.45,
|
||||
costUsd: 0.67,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-object and irrelevant payloads", () => {
|
||||
expect(summarizeHeartbeatRunResultJson(null)).toBeNull();
|
||||
expect(summarizeHeartbeatRunResultJson(["nope"] as unknown as Record<string, unknown>)).toBeNull();
|
||||
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,16 @@
|
|||
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 {
|
||||
applyPersistedExecutionWorkspaceConfig,
|
||||
buildRealizedExecutionWorkspaceFromPersisted,
|
||||
buildExplicitResumeSessionOverride,
|
||||
formatRuntimeWorkspaceWarningLog,
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
parseSessionCompactionPolicy,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
stripWorkspaceRuntimeFromExecutionRunConfig,
|
||||
shouldResetTaskSessionForWake,
|
||||
type ResolvedWorkspaceForRun,
|
||||
} from "../services/heartbeat.ts";
|
||||
|
|
@ -20,6 +29,32 @@ function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}
|
|||
};
|
||||
}
|
||||
|
||||
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
name: "Agent",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "running",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType,
|
||||
adapterConfig: {},
|
||||
runtimeConfig,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
permissions: {},
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as typeof agents.$inferSelect;
|
||||
}
|
||||
|
||||
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
||||
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
||||
const agentId = "agent-123";
|
||||
|
|
@ -88,21 +123,172 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("applyPersistedExecutionWorkspaceConfig", () => {
|
||||
it("does not add workspace runtime when only the project workspace had manual runtime config", () => {
|
||||
const result = applyPersistedExecutionWorkspaceConfig({
|
||||
config: {},
|
||||
workspaceConfig: null,
|
||||
mode: "isolated_workspace",
|
||||
});
|
||||
|
||||
expect("workspaceRuntime" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("applies explicit persisted execution workspace runtime config when present", () => {
|
||||
const result = applyPersistedExecutionWorkspaceConfig({
|
||||
config: {},
|
||||
workspaceConfig: {
|
||||
provisionCommand: null,
|
||||
teardownCommand: null,
|
||||
cleanupCommand: null,
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "workspace-web" }],
|
||||
},
|
||||
},
|
||||
mode: "isolated_workspace",
|
||||
});
|
||||
|
||||
expect(result.workspaceRuntime).toEqual({
|
||||
services: [{ name: "workspace-web" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
|
||||
it("reuses the persisted execution workspace path instead of deriving a new worktree", () => {
|
||||
const result = buildRealizedExecutionWorkspaceFromPersisted({
|
||||
base: buildResolvedWorkspace({
|
||||
cwd: "/tmp/project-primary",
|
||||
repoRef: "main",
|
||||
}),
|
||||
workspace: {
|
||||
id: "execution-workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
sourceIssueId: "issue-1",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-880-thumbs-capture-for-evals-feature",
|
||||
status: "active",
|
||||
cwd: "/tmp/reused-worktree",
|
||||
repoUrl: "https://example.com/paperclip.git",
|
||||
baseRef: "main",
|
||||
branchName: "PAP-880-thumbs-capture-for-evals-feature",
|
||||
providerType: "git_worktree",
|
||||
providerRef: "/tmp/reused-worktree",
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.created).toBe(false);
|
||||
expect(result.strategy).toBe("git_worktree");
|
||||
expect(result.cwd).toBe("/tmp/reused-worktree");
|
||||
expect(result.worktreePath).toBe("/tmp/reused-worktree");
|
||||
expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature");
|
||||
expect(result.source).toBe("task_session");
|
||||
});
|
||||
|
||||
it("falls back to realization when the persisted workspace has no local path yet", () => {
|
||||
const result = buildRealizedExecutionWorkspaceFromPersisted({
|
||||
base: buildResolvedWorkspace({
|
||||
cwd: "/tmp/project-primary",
|
||||
repoRef: "main",
|
||||
}),
|
||||
workspace: {
|
||||
id: "execution-workspace-2",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
sourceIssueId: "issue-2",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-999-missing-provider-ref",
|
||||
status: "active",
|
||||
cwd: null,
|
||||
repoUrl: "https://example.com/paperclip.git",
|
||||
baseRef: "main",
|
||||
branchName: "feature/PAP-999-missing-provider-ref",
|
||||
providerType: "git_worktree",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
|
||||
it("removes workspace runtime before heartbeat execution", () => {
|
||||
const input = {
|
||||
cwd: "/tmp/project",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = stripWorkspaceRuntimeFromExecutionRunConfig(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
cwd: "/tmp/project",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
},
|
||||
});
|
||||
expect(input.workspaceRuntime).toEqual({
|
||||
services: [{ name: "web" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResetTaskSessionForWake", () => {
|
||||
it("resets session context on assignment wake", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
||||
});
|
||||
|
||||
it("resets session context on timer heartbeats", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(true);
|
||||
it("preserves session context on timer heartbeats", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
|
||||
});
|
||||
|
||||
it("resets session context on manual on-demand invokes", () => {
|
||||
it("preserves session context on manual on-demand invokes by default", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForWake({
|
||||
wakeSource: "on_demand",
|
||||
wakeTriggerDetail: "manual",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resets session context when a fresh session is explicitly requested", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForWake({
|
||||
wakeSource: "on_demand",
|
||||
wakeTriggerDetail: "manual",
|
||||
forceFreshSession: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -141,3 +327,151 @@ describe("shouldResetTaskSessionForWake", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
stream: "stdout",
|
||||
chunk: "[paperclip] Using fallback workspace\n",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("prioritizeProjectWorkspaceCandidatesForRun", () => {
|
||||
it("moves the explicitly selected workspace to the front", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1", cwd: "/tmp/one" },
|
||||
{ id: "workspace-2", cwd: "/tmp/two" },
|
||||
{ id: "workspace-3", cwd: "/tmp/three" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-2").map((row) => row.id),
|
||||
).toEqual(["workspace-2", "workspace-1", "workspace-3"]);
|
||||
});
|
||||
|
||||
it("keeps the original order when no preferred workspace is selected", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1" },
|
||||
{ id: "workspace-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, null).map((row) => row.id),
|
||||
).toEqual(["workspace-1", "workspace-2"]);
|
||||
});
|
||||
|
||||
it("keeps the original order when the selected workspace is missing", () => {
|
||||
const rows = [
|
||||
{ id: "workspace-1" },
|
||||
{ id: "workspace-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-9").map((row) => row.id),
|
||||
).toEqual(["workspace-1", "workspace-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSessionCompactionPolicy", () => {
|
||||
it("disables Paperclip-managed rotation by default for codex and claude local", () => {
|
||||
expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 0,
|
||||
});
|
||||
expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps conservative defaults for adapters without confirmed native compaction", () => {
|
||||
expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 200,
|
||||
maxRawInputTokens: 2_000_000,
|
||||
maxSessionAgeHours: 72,
|
||||
});
|
||||
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 200,
|
||||
maxRawInputTokens: 2_000_000,
|
||||
maxSessionAgeHours: 72,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicit agent overrides win over adapter defaults", () => {
|
||||
expect(
|
||||
parseSessionCompactionPolicy(
|
||||
buildAgent("codex_local", {
|
||||
heartbeat: {
|
||||
sessionCompaction: {
|
||||
maxSessionRuns: 25,
|
||||
maxRawInputTokens: 500_000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 25,
|
||||
maxRawInputTokens: 500_000,
|
||||
maxSessionAgeHours: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
6
server/src/__tests__/helpers/embedded-postgres.ts
Normal file
6
server/src/__tests__/helpers/embedded-postgres.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestSupport,
|
||||
} from "@paperclipai/db";
|
||||
156
server/src/__tests__/instance-settings-routes.test.ts
Normal file
156
server/src/__tests__/instance-settings-routes.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp(actor: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", instanceSettingsRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("allows local board users to read and update experimental settings", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
const getRes = await request(app).get("/api/instance/settings/experimental");
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body).toEqual({
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
});
|
||||
|
||||
const patchRes = await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableIsolatedWorkspaces: true });
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableIsolatedWorkspaces: true,
|
||||
});
|
||||
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",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/instance/settings/general");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent callers", async () => {
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings/general")
|
||||
.send({ censorUsernameInLogs: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
57
server/src/__tests__/invite-join-grants.test.ts
Normal file
57
server/src/__tests__/invite-join-grants.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
|
||||
|
||||
describe("agentJoinGrantsFromDefaults", () => {
|
||||
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
|
||||
expect(agentJoinGrantsFromDefaults(null)).toEqual([
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves invite agent grants and appends tasks:assign", () => {
|
||||
expect(
|
||||
agentJoinGrantsFromDefaults({
|
||||
agent: {
|
||||
grants: [
|
||||
{
|
||||
permissionKey: "agents:create",
|
||||
scope: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
permissionKey: "agents:create",
|
||||
scope: null,
|
||||
},
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate tasks:assign when invite defaults already include it", () => {
|
||||
expect(
|
||||
agentJoinGrantsFromDefaults({
|
||||
agent: {
|
||||
grants: [
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: { projectId: "project-1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: { projectId: "project-1" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
191
server/src/__tests__/issue-comment-reopen-routes.test.ts
Normal file
191
server/src/__tests__/issue-comment-reopen-routes.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("interrupts an active run before a combined comment update", async () => {
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
executionRunId: "run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
}));
|
||||
mockHeartbeatService.getRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "running",
|
||||
});
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1");
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "heartbeat.cancelled",
|
||||
details: expect.objectContaining({
|
||||
source: "issue_comment_interrupt",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
99
server/src/__tests__/issue-goal-fallback.test.ts
Normal file
99
server/src/__tests__/issue-goal-fallback.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveIssueGoalId,
|
||||
resolveNextIssueGoalId,
|
||||
} from "../services/issue-goal-fallback.ts";
|
||||
|
||||
describe("issue goal fallback", () => {
|
||||
it("assigns the company goal when creating an issue without project or goal", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
|
||||
it("keeps an explicit goal when creating an issue", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: null,
|
||||
goalId: "goal-2",
|
||||
projectGoalId: "goal-3",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("inherits the project goal when creating a project-linked issue", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("does not force a company goal when the project has no goal", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("backfills the company goal on update for legacy no-project issues", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: null,
|
||||
currentGoalId: null,
|
||||
currentProjectGoalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
|
||||
it("switches from the company fallback to the project goal when a project is added later", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: null,
|
||||
currentGoalId: "goal-1",
|
||||
currentProjectGoalId: null,
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("backfills the project goal for legacy project-linked issues on update", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: "project-1",
|
||||
currentGoalId: null,
|
||||
currentProjectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("preserves an explicit goal across project fallback changes", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: "project-1",
|
||||
currentGoalId: "goal-explicit",
|
||||
currentProjectGoalId: "goal-2",
|
||||
projectId: "project-2",
|
||||
projectGoalId: "goal-3",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-explicit");
|
||||
});
|
||||
});
|
||||
187
server/src/__tests__/issues-goal-context-routes.test.ts
Normal file
187
server/src/__tests__/issues-goal-context-routes.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
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(),
|
||||
getAncestors: vi.fn(),
|
||||
findMentionedProjectIds: vi.fn(),
|
||||
getCommentCursor: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
listByIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGoalService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getDefaultCompanyGoal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
documentService: () => ({
|
||||
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||
}),
|
||||
executionWorkspaceService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
goalService: () => mockGoalService,
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => mockProjectService,
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
}),
|
||||
}));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const legacyProjectLinkedIssue = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-581",
|
||||
title: "Legacy onboarding task",
|
||||
description: "Seed the first CEO task",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
projectId: "22222222-2222-4222-8222-222222222222",
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
updatedAt: new Date("2026-03-24T12:00:00Z"),
|
||||
executionWorkspaceId: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
};
|
||||
|
||||
const projectGoal = {
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
companyId: "company-1",
|
||||
title: "Launch the company",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||
};
|
||||
|
||||
describe("issue goal context routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
|
||||
mockIssueService.getCommentCursor.mockResolvedValue({
|
||||
totalComments: 0,
|
||||
latestCommentId: null,
|
||||
latestCommentAt: null,
|
||||
});
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockProjectService.getById.mockResolvedValue({
|
||||
id: legacyProjectLinkedIssue.projectId,
|
||||
companyId: "company-1",
|
||||
urlKey: "onboarding",
|
||||
goalId: projectGoal.id,
|
||||
goalIds: [projectGoal.id],
|
||||
goals: [{ id: projectGoal.id, title: projectGoal.title }],
|
||||
name: "Onboarding",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/company-1/project-1",
|
||||
effectiveLocalFolder: "/tmp/company-1/project-1",
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||
});
|
||||
mockProjectService.listByIds.mockResolvedValue([]);
|
||||
mockGoalService.getById.mockImplementation(async (id: string) =>
|
||||
id === projectGoal.id ? projectGoal : null,
|
||||
);
|
||||
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
|
||||
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.goalId).toBe(projectGoal.id);
|
||||
expect(res.body.goal).toEqual(
|
||||
expect.objectContaining({
|
||||
id: projectGoal.id,
|
||||
title: projectGoal.title,
|
||||
}),
|
||||
);
|
||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
|
||||
const res = await request(createApp()).get(
|
||||
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.issue.goalId).toBe(projectGoal.id);
|
||||
expect(res.body.goal).toEqual(
|
||||
expect.objectContaining({
|
||||
id: projectGoal.id,
|
||||
title: projectGoal.title,
|
||||
}),
|
||||
);
|
||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
680
server/src/__tests__/issues-service.test.ts
Normal file
680
server/src/__tests__/issues-service.test.ts
Normal file
|
|
@ -0,0 +1,680 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
instanceSettings,
|
||||
issueComments,
|
||||
issueInboxArchives,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
it("filters issues by execution workspace id", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const targetWorkspaceId = randomUUID();
|
||||
const otherWorkspaceId = randomUUID();
|
||||
const linkedIssueId = randomUUID();
|
||||
const otherLinkedIssueId = randomUUID();
|
||||
const unlinkedIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: targetWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Target workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
{
|
||||
id: otherWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Other workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: linkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Linked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: targetWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: otherLinkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Other linked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: otherWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: unlinkedIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Unlinked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId });
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]);
|
||||
});
|
||||
|
||||
it("hides archived inbox issues until new external activity arrives", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "user-1";
|
||||
const otherUserId = "user-2";
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
const visibleIssueId = randomUUID();
|
||||
const archivedIssueId = randomUUID();
|
||||
const resurfacedIssueId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: visibleIssueId,
|
||||
companyId,
|
||||
title: "Visible issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: archivedIssueId,
|
||||
companyId,
|
||||
title: "Archived issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: resurfacedIssueId,
|
||||
companyId,
|
||||
title: "Resurfaced issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await svc.archiveInbox(
|
||||
companyId,
|
||||
archivedIssueId,
|
||||
userId,
|
||||
new Date("2026-03-26T12:30:00.000Z"),
|
||||
);
|
||||
await svc.archiveInbox(
|
||||
companyId,
|
||||
resurfacedIssueId,
|
||||
userId,
|
||||
new Date("2026-03-26T13:00:00.000Z"),
|
||||
);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: resurfacedIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "This should bring the issue back into Mine.",
|
||||
createdAt: new Date("2026-03-26T13:30:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
|
||||
});
|
||||
|
||||
const archivedFiltered = await svc.list(companyId, {
|
||||
touchedByUserId: userId,
|
||||
inboxArchivedByUserId: userId,
|
||||
});
|
||||
|
||||
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
|
||||
resurfacedIssueId,
|
||||
visibleIssueId,
|
||||
]);
|
||||
|
||||
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
|
||||
|
||||
const afterUnarchive = await svc.list(companyId, {
|
||||
touchedByUserId: userId,
|
||||
inboxArchivedByUserId: userId,
|
||||
});
|
||||
|
||||
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
|
||||
visibleIssueId,
|
||||
archivedIssueId,
|
||||
resurfacedIssueId,
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
sharedWorkspaceKey: "workspace-key",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Issue worktree",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
providerRef: `/tmp/${executionWorkspaceId}`,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
workspaceRuntime: { profile: "agent" },
|
||||
},
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parentIssueId,
|
||||
projectId,
|
||||
title: "Child issue",
|
||||
});
|
||||
|
||||
expect(child.parentId).toBe(parentIssueId);
|
||||
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(child.executionWorkspaceSettings).toEqual({
|
||||
mode: "isolated_workspace",
|
||||
workspaceRuntime: { profile: "agent" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
const parentProjectWorkspaceId = randomUUID();
|
||||
const parentExecutionWorkspaceId = randomUUID();
|
||||
const explicitProjectWorkspaceId = randomUUID();
|
||||
const explicitExecutionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{
|
||||
id: parentProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Parent workspace",
|
||||
},
|
||||
{
|
||||
id: explicitProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Explicit workspace",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: parentExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: parentProjectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Parent worktree",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
},
|
||||
{
|
||||
id: explicitExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: explicitProjectWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Explicit shared workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: parentProjectWorkspaceId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: parentExecutionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
},
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parentIssueId,
|
||||
projectId,
|
||||
title: "Child issue",
|
||||
projectWorkspaceId: explicitProjectWorkspaceId,
|
||||
executionWorkspaceId: explicitExecutionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "shared_workspace",
|
||||
},
|
||||
});
|
||||
|
||||
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
|
||||
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
|
||||
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(child.executionWorkspaceSettings).toEqual({
|
||||
mode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const sourceIssueId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "operator_branch",
|
||||
strategyType: "git_worktree",
|
||||
name: "Operator branch",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: sourceIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Source issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "operator_branch",
|
||||
},
|
||||
});
|
||||
|
||||
const followUp = await svc.create(companyId, {
|
||||
projectId,
|
||||
title: "Follow-up issue",
|
||||
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
||||
});
|
||||
|
||||
expect(followUp.parentId).toBeNull();
|
||||
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
||||
expect(followUp.executionWorkspaceSettings).toEqual({
|
||||
mode: "operator_branch",
|
||||
});
|
||||
});
|
||||
});
|
||||
74
server/src/__tests__/log-redaction.test.ts
Normal file
74
server/src/__tests__/log-redaction.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
maskUserNameForLogs,
|
||||
redactCurrentUserText,
|
||||
redactCurrentUserValue,
|
||||
} from "../log-redaction.js";
|
||||
|
||||
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`,
|
||||
`win=C:\\Users\\${userName}\\paperclip`,
|
||||
].join("\n");
|
||||
|
||||
const result = redactCurrentUserText(input, {
|
||||
userNames: [userName],
|
||||
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
|
||||
});
|
||||
|
||||
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`,
|
||||
{
|
||||
userNames: [userName],
|
||||
homeDirs: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
`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`,
|
||||
nested: {
|
||||
author: userName,
|
||||
},
|
||||
values: [userName, `/home/${userName}/project`],
|
||||
}, {
|
||||
userNames: [userName],
|
||||
homeDirs: [`/Users/${userName}`, `/home/${userName}`],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
cwd: `/Users/${maskedUserName}/paperclip`,
|
||||
prompt: `open /Users/${maskedUserName}/paperclip/ui`,
|
||||
nested: {
|
||||
author: maskedUserName,
|
||||
},
|
||||
values: [maskedUserName, `/home/${maskedUserName}/project`],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips redaction when disabled", () => {
|
||||
const input = "cwd=/Users/paperclipuser/paperclip";
|
||||
expect(redactCurrentUserText(input, { enabled: false })).toBe(input);
|
||||
});
|
||||
});
|
||||
90
server/src/__tests__/monthly-spend-service.test.ts
Normal file
90
server/src/__tests__/monthly-spend-service.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { companyService } from "../services/companies.ts";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
|
||||
function createSelectSequenceDb(results: unknown[]) {
|
||||
const pending = [...results];
|
||||
const chain = {
|
||||
from: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
leftJoin: vi.fn(() => chain),
|
||||
groupBy: vi.fn(() => chain),
|
||||
then: vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pending.shift() ?? []))),
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => chain),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("monthly spend hydration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("recomputes company spentMonthlyCents from the current utc month instead of returning stale stored values", async () => {
|
||||
const dbStub = createSelectSequenceDb([
|
||||
[{
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
description: null,
|
||||
status: "active",
|
||||
issuePrefix: "PAP",
|
||||
issueCounter: 1,
|
||||
budgetMonthlyCents: 5000,
|
||||
spentMonthlyCents: 999999,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
spentMonthlyCents: 420,
|
||||
}],
|
||||
]);
|
||||
|
||||
const companies = companyService(dbStub.db as any);
|
||||
const [company] = await companies.list();
|
||||
|
||||
expect(company.spentMonthlyCents).toBe(420);
|
||||
});
|
||||
|
||||
it("recomputes agent spentMonthlyCents from the current utc month instead of returning stale stored values", async () => {
|
||||
const dbStub = createSelectSequenceDb([
|
||||
[{
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Budget Agent",
|
||||
role: "general",
|
||||
title: null,
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "claude-local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 5000,
|
||||
spentMonthlyCents: 999999,
|
||||
metadata: null,
|
||||
permissions: null,
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
[{
|
||||
agentId: "agent-1",
|
||||
spentMonthlyCents: 175,
|
||||
}],
|
||||
]);
|
||||
|
||||
const agents = agentService(dbStub.db as any);
|
||||
const agent = await agents.getById("agent-1");
|
||||
|
||||
expect(agent?.spentMonthlyCents).toBe(175);
|
||||
});
|
||||
});
|
||||
41
server/src/__tests__/normalize-agent-mention-token.test.ts
Normal file
41
server/src/__tests__/normalize-agent-mention-token.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAgentMentionToken } from "../services/issues.ts";
|
||||
|
||||
describe("normalizeAgentMentionToken", () => {
|
||||
it("decodes hex numeric entities such as space ( )", () => {
|
||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
||||
});
|
||||
|
||||
it("decodes decimal numeric entities", () => {
|
||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
||||
});
|
||||
|
||||
it("decodes common named whitespace entities", () => {
|
||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
||||
});
|
||||
|
||||
// Mid-token entity (review asked for this shape); we decode &→&, not strip to "Baba" (that broke M&M).
|
||||
it("decodes a named entity in the middle of the token", () => {
|
||||
expect(normalizeAgentMentionToken("Ba&ba")).toBe("Ba&ba");
|
||||
});
|
||||
|
||||
it("decodes & so agent names with ampersands still match", () => {
|
||||
expect(normalizeAgentMentionToken("M&M")).toBe("M&M");
|
||||
});
|
||||
|
||||
it("decodes additional named entities used in rich text (e.g. ©)", () => {
|
||||
expect(normalizeAgentMentionToken("Agent©Name")).toBe("Agent©Name");
|
||||
});
|
||||
|
||||
it("leaves unknown semicolon-terminated named references unchanged", () => {
|
||||
expect(normalizeAgentMentionToken("Baba¬arealentity;")).toBe("Baba¬arealentity;");
|
||||
});
|
||||
|
||||
it("returns plain names unchanged", () => {
|
||||
expect(normalizeAgentMentionToken("Baba")).toBe("Baba");
|
||||
});
|
||||
|
||||
it("trims after decoding entities", () => {
|
||||
expect(normalizeAgentMentionToken("Baba  ")).toBe("Baba");
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
import { createServer } from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
|
||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import {
|
||||
buildOpenClawGatewayConfig,
|
||||
parseOpenClawGatewayStdoutLine,
|
||||
} from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
function buildContext(
|
||||
|
|
@ -36,7 +39,9 @@ function buildContext(
|
|||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServer() {
|
||||
async function createMockGatewayServer(options?: {
|
||||
waitPayload?: Record<string, unknown>;
|
||||
}) {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
|
|
@ -136,7 +141,7 @@ async function createMockGatewayServer() {
|
|||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
payload: options?.waitPayload ?? {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
|
|
@ -412,6 +417,29 @@ describe("openclaw gateway adapter execute", () => {
|
|||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
context: {
|
||||
taskId: "task-123",
|
||||
issueId: "issue-123",
|
||||
wakeReason: "issue_assigned",
|
||||
issueIds: ["issue-123"],
|
||||
paperclipWorkspace: {
|
||||
cwd: "/tmp/worktrees/pap-123",
|
||||
strategy: "git_worktree",
|
||||
branchName: "pap-123-test",
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
id: "workspace-1",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
],
|
||||
paperclipRuntimeServiceIntents: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -441,6 +469,54 @@ describe("openclaw gateway adapter execute", () => {
|
|||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||
});
|
||||
|
||||
it("returns adapter-managed runtime services from gateway result meta", async () => {
|
||||
const gateway = await createMockGatewayServer({
|
||||
waitPayload: {
|
||||
runId: "run-123",
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
meta: {
|
||||
runtimeServices: [
|
||||
{
|
||||
name: "preview",
|
||||
scopeType: "run",
|
||||
url: "https://preview.example/run-123",
|
||||
providerRef: "sandbox-123",
|
||||
lifecycle: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.runtimeServices).toEqual([
|
||||
expect.objectContaining({
|
||||
serviceName: "preview",
|
||||
scopeType: "run",
|
||||
url: "https://preview.example/run-123",
|
||||
providerRef: "sandbox-123",
|
||||
lifecycle: "ephemeral",
|
||||
status: "running",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-approves pairing once and retries the run", async () => {
|
||||
const gateway = await createMockGatewayServerWithPairing();
|
||||
const logs: string[] = [];
|
||||
|
|
@ -479,6 +555,62 @@ describe("openclaw gateway adapter execute", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway ui build config", () => {
|
||||
it("parses payload template and runtime services json", () => {
|
||||
const config = buildOpenClawGatewayConfig({
|
||||
adapterType: "openclaw_gateway",
|
||||
cwd: "",
|
||||
promptTemplate: "",
|
||||
model: "",
|
||||
thinkingEffort: "",
|
||||
chrome: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
search: false,
|
||||
dangerouslyBypassSandbox: false,
|
||||
command: "",
|
||||
args: "",
|
||||
extraArgs: "",
|
||||
envVars: "",
|
||||
envBindings: {},
|
||||
url: "wss://gateway.example/ws",
|
||||
payloadTemplateJson: JSON.stringify({
|
||||
agentId: "remote-agent-123",
|
||||
metadata: { team: "platform" },
|
||||
}),
|
||||
runtimeServicesJson: JSON.stringify({
|
||||
services: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "shared",
|
||||
},
|
||||
],
|
||||
}),
|
||||
bootstrapPrompt: "",
|
||||
maxTurnsPerRun: 0,
|
||||
heartbeatEnabled: true,
|
||||
intervalSec: 300,
|
||||
});
|
||||
|
||||
expect(config).toEqual(
|
||||
expect.objectContaining({
|
||||
url: "wss://gateway.example/ws",
|
||||
payloadTemplate: {
|
||||
agentId: "remote-agent-123",
|
||||
metadata: { team: "platform" },
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "shared",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway testEnvironment", () => {
|
||||
it("reports missing url as failure", async () => {
|
||||
const result = await testEnvironment({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { companies, invites } from "@paperclipai/db";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
|
|
@ -23,11 +24,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(),
|
||||
|
|
@ -40,19 +52,35 @@ function createDbStub() {
|
|||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
|
||||
expiresAt: new Date("2099-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
tokenHash: "hash",
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
createdAt: new Date("2099-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2099-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const returning = vi.fn().mockResolvedValue([createdInvite]);
|
||||
const values = vi.fn().mockReturnValue({ returning });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
const select = vi.fn(() => ({
|
||||
from(table: unknown) {
|
||||
return {
|
||||
where: vi.fn().mockImplementation(() => {
|
||||
if (table === invites) {
|
||||
return Promise.resolve([createdInvite]);
|
||||
}
|
||||
if (table === companies) {
|
||||
return Promise.resolve([{ name: "Acme AI" }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
return {
|
||||
insert,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -132,9 +160,30 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
|||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
expect(typeof res.body.token).toBe("string");
|
||||
expect(res.body.companyName).toBe("Acme AI");
|
||||
expect(res.body.onboardingTextPath).toContain("/api/invites/");
|
||||
});
|
||||
|
||||
it("includes companyName in invite summary responses", async () => {
|
||||
const db = createDbStub();
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyId).toBe("company-1");
|
||||
expect(res.body.companyName).toBe("Acme AI");
|
||||
});
|
||||
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ describe("opencode_local ui stdout parser", () => {
|
|||
kind: "tool_call",
|
||||
ts,
|
||||
name: "bash",
|
||||
toolUseId: "call_1",
|
||||
input: { command: "ls -1" },
|
||||
},
|
||||
{
|
||||
|
|
|
|||
90
server/src/__tests__/opencode-local-skill-sync.test.ts
Normal file
90
server/src/__tests__/opencode-local-skill-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
62
server/src/__tests__/paperclip-skill-utils.test.ts
Normal file
62
server/src/__tests__/paperclip-skill-utils.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listPaperclipSkillEntries,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("paperclip skill utils", () => {
|
||||
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("lists runtime skills from ./skills without pulling in .agents/skills", async () => {
|
||||
const root = await makeTempDir("paperclip-skill-roots-");
|
||||
cleanupDirs.add(root);
|
||||
|
||||
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
|
||||
await fs.mkdir(moduleDir, { recursive: true });
|
||||
await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true });
|
||||
|
||||
const entries = await listPaperclipSkillEntries(moduleDir);
|
||||
|
||||
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"));
|
||||
});
|
||||
|
||||
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
|
||||
const root = await makeTempDir("paperclip-skill-cleanup-");
|
||||
cleanupDirs.add(root);
|
||||
|
||||
const skillsHome = path.join(root, "skills-home");
|
||||
const runtimeSkill = path.join(root, "skills", "paperclip");
|
||||
const customSkill = path.join(root, "custom", "release-notes");
|
||||
const staleMaintainerSkill = path.join(root, ".agents", "skills", "release");
|
||||
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
await fs.mkdir(runtimeSkill, { recursive: true });
|
||||
await fs.mkdir(customSkill, { recursive: true });
|
||||
|
||||
await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip"));
|
||||
await fs.symlink(customSkill, path.join(skillsHome, "release-notes"));
|
||||
await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release"));
|
||||
|
||||
const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]);
|
||||
|
||||
expect(removed).toEqual(["release"]);
|
||||
await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow();
|
||||
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
101
server/src/__tests__/pi-local-adapter-environment.test.ts
Normal file
101
server/src/__tests__/pi-local-adapter-environment.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
89
server/src/__tests__/pi-local-skill-sync.test.ts
Normal file
89
server/src/__tests__/pi-local-skill-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
68
server/src/__tests__/plugin-dev-watcher.test.ts
Normal file
68
server/src/__tests__/plugin-dev-watcher.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolvePluginWatchTargets } from "../services/plugin-dev-watcher.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempPluginDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-plugin-watch-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("resolvePluginWatchTargets", () => {
|
||||
it("watches package metadata plus concrete declared runtime files", () => {
|
||||
const pluginDir = makeTempPluginDir();
|
||||
mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@acme/example",
|
||||
paperclipPlugin: {
|
||||
manifest: "./dist/manifest.js",
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "worker.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "ui", "index.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "ui", "index.css"), "body {}\n");
|
||||
|
||||
const targets = resolvePluginWatchTargets(pluginDir);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ path: path.join(pluginDir, "dist", "manifest.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "ui", "index.css"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "ui", "index.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "worker.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "package.json"), recursive: false, kind: "file" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to dist when package metadata does not declare entrypoints", () => {
|
||||
const pluginDir = makeTempPluginDir();
|
||||
mkdirSync(path.join(pluginDir, "dist", "nested"), { recursive: true });
|
||||
writeFileSync(path.join(pluginDir, "package.json"), JSON.stringify({ name: "@acme/example" }));
|
||||
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "nested", "chunk.js"), "export default {};\n");
|
||||
|
||||
const targets = resolvePluginWatchTargets(pluginDir);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ path: path.join(pluginDir, "package.json"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "manifest.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "nested", "chunk.js"), recursive: false, kind: "file" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
43
server/src/__tests__/plugin-worker-manager.test.ts
Normal file
43
server/src/__tests__/plugin-worker-manager.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js";
|
||||
|
||||
describe("plugin-worker-manager stderr failure context", () => {
|
||||
it("appends worker stderr context to failure messages", () => {
|
||||
expect(
|
||||
formatWorkerFailureMessage(
|
||||
"Worker process exited (code=1, signal=null)",
|
||||
"TypeError: Unknown file extension \".ts\"",
|
||||
),
|
||||
).toBe(
|
||||
"Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate stderr that is already present", () => {
|
||||
const message = [
|
||||
"Worker process exited (code=1, signal=null)",
|
||||
"",
|
||||
"Worker stderr:",
|
||||
"TypeError: Unknown file extension \".ts\"",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""),
|
||||
).toBe(message);
|
||||
});
|
||||
|
||||
it("keeps only the latest stderr excerpt", () => {
|
||||
let excerpt = "";
|
||||
excerpt = appendStderrExcerpt(excerpt, "first line");
|
||||
excerpt = appendStderrExcerpt(excerpt, "second line");
|
||||
|
||||
expect(excerpt).toContain("first line");
|
||||
expect(excerpt).toContain("second line");
|
||||
|
||||
excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000));
|
||||
|
||||
expect(excerpt).not.toContain("first line");
|
||||
expect(excerpt).not.toContain("second line");
|
||||
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
});
|
||||
|
|
@ -52,5 +52,5 @@ describe("privateHostnameGuard", () => {
|
|||
const res = await request(app).get("/dashboard").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.text).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
|
|
|
|||
56
server/src/__tests__/quota-windows-service.test.ts
Normal file
56
server/src/__tests__/quota-windows-service.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../adapters/registry.js", () => ({
|
||||
listServerAdapters: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listServerAdapters } from "../adapters/registry.js";
|
||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||
|
||||
describe("fetchAllQuotaWindows", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns adapter results without waiting for a slower provider to finish forever", async () => {
|
||||
vi.mocked(listServerAdapters).mockReturnValue([
|
||||
{
|
||||
type: "codex_local",
|
||||
getQuotaWindows: vi.fn().mockResolvedValue({
|
||||
provider: "openai",
|
||||
source: "codex-rpc",
|
||||
ok: true,
|
||||
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "claude_local",
|
||||
getQuotaWindows: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const promise = fetchAllQuotaWindows();
|
||||
await vi.advanceTimersByTimeAsync(20_001);
|
||||
const results = await promise;
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
provider: "openai",
|
||||
source: "codex-rpc",
|
||||
ok: true,
|
||||
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error: "quota polling timed out after 20s",
|
||||
windows: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
812
server/src/__tests__/quota-windows.test.ts
Normal file
812
server/src/__tests__/quota-windows.test.ts
Normal file
|
|
@ -0,0 +1,812 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { QuotaWindow } from "@paperclipai/adapter-utils";
|
||||
|
||||
// Pure utility functions — import directly from adapter source
|
||||
import {
|
||||
toPercent,
|
||||
fetchWithTimeout,
|
||||
fetchClaudeQuota,
|
||||
parseClaudeCliUsageText,
|
||||
readClaudeToken,
|
||||
claudeConfigDir,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
import {
|
||||
secondsToWindowLabel,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
fetchCodexQuota,
|
||||
mapCodexRpcQuota,
|
||||
codexHomeDir,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toPercent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("toPercent", () => {
|
||||
it("returns null for null input", () => {
|
||||
expect(toPercent(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for undefined input", () => {
|
||||
expect(toPercent(undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("converts 0 to 0", () => {
|
||||
expect(toPercent(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("converts 0.5 to 50", () => {
|
||||
expect(toPercent(0.5)).toBe(50);
|
||||
});
|
||||
|
||||
it("converts 1.0 to 100", () => {
|
||||
expect(toPercent(1.0)).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps overshoot to 100", () => {
|
||||
// floating-point utilization can slightly exceed 1.0
|
||||
expect(toPercent(1.001)).toBe(100);
|
||||
expect(toPercent(1.01)).toBe(100);
|
||||
});
|
||||
|
||||
it("rounds to nearest integer", () => {
|
||||
expect(toPercent(0.333)).toBe(33);
|
||||
expect(toPercent(0.666)).toBe(67);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// secondsToWindowLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("secondsToWindowLabel", () => {
|
||||
it("returns fallback for null seconds", () => {
|
||||
expect(secondsToWindowLabel(null, "Primary")).toBe("Primary");
|
||||
});
|
||||
|
||||
it("returns fallback for undefined seconds", () => {
|
||||
expect(secondsToWindowLabel(undefined, "Secondary")).toBe("Secondary");
|
||||
});
|
||||
|
||||
it("labels windows under 6 hours as '5h'", () => {
|
||||
expect(secondsToWindowLabel(3600, "fallback")).toBe("5h"); // 1h
|
||||
expect(secondsToWindowLabel(18000, "fallback")).toBe("5h"); // 5h exactly
|
||||
});
|
||||
|
||||
it("labels windows up to 24 hours as '24h'", () => {
|
||||
expect(secondsToWindowLabel(21600, "fallback")).toBe("24h"); // 6h (≥6h boundary)
|
||||
expect(secondsToWindowLabel(86400, "fallback")).toBe("24h"); // 24h exactly
|
||||
});
|
||||
|
||||
it("labels windows up to 7 days as '7d'", () => {
|
||||
expect(secondsToWindowLabel(86401, "fallback")).toBe("7d"); // just over 24h
|
||||
expect(secondsToWindowLabel(604800, "fallback")).toBe("7d"); // 7d exactly
|
||||
});
|
||||
|
||||
it("labels windows beyond 7 days with actual day count", () => {
|
||||
expect(secondsToWindowLabel(1209600, "fallback")).toBe("14d"); // 14d
|
||||
expect(secondsToWindowLabel(2592000, "fallback")).toBe("30d"); // 30d
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WHAM used_percent normalization (codex / openai)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("WHAM used_percent normalization via fetchCodexQuota", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(body: unknown) {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
it("treats values >= 1 as already-percentage (50 → 50%)", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 50,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(50);
|
||||
});
|
||||
|
||||
it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 0.5,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(50);
|
||||
});
|
||||
|
||||
it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", async () => {
|
||||
// 1.0 is NOT < 1, so it is treated as already-percentage → 1%
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 1.0,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(1);
|
||||
});
|
||||
|
||||
it("treats value 0 as 0%", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 0,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps 100% to 100 (no overshoot)", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 105,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(100);
|
||||
});
|
||||
|
||||
it("sets usedPercent to null when used_percent is absent", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.usedPercent).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readClaudeToken — filesystem paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readClaudeToken", () => {
|
||||
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when credentials.json does not exist", async () => {
|
||||
// Point to a directory that does not have credentials.json
|
||||
process.env.CLAUDE_CONFIG_DIR = "/tmp/__no_such_paperclip_dir__";
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "credentials.json"), "not-json"),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns null when claudeAiOauth key is missing", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify({ other: "data" })),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns null when accessToken is an empty string", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
const creds = { claudeAiOauth: { accessToken: "" } };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify(creds)),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns the token when credentials file is well-formed", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
const creds = { claudeAiOauth: { accessToken: "my-test-token" } };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify(creds)),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe("my-test-token");
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("reads the token from .credentials.json when that is the available Claude auth file", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
const creds = { claudeAiOauth: { accessToken: "dotfile-token" } };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, ".credentials.json"), JSON.stringify(creds)),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe("dotfile-token");
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCliUsageText", () => {
|
||||
it("parses the Claude usage panel layout into quota windows", () => {
|
||||
const raw = `
|
||||
Settings: Status Config Usage
|
||||
Current session
|
||||
2% used
|
||||
Resets 5pm (America/Chicago)
|
||||
|
||||
Current week (all models)
|
||||
47% used
|
||||
Resets Mar 18 at 7:59am (America/Chicago)
|
||||
|
||||
Current week (Sonnet only)
|
||||
0% used
|
||||
Resets Mar 18 at 8:59am (America/Chicago)
|
||||
|
||||
Extra usage
|
||||
Extra usage not enabled • /extra-usage to enable
|
||||
`;
|
||||
|
||||
expect(parseClaudeCliUsageText(raw)).toEqual([
|
||||
{
|
||||
label: "Current session",
|
||||
usedPercent: 2,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets 5pm (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 47,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets Mar 18 at 7:59am (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Current week (Sonnet only)",
|
||||
usedPercent: 0,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets Mar 18 at 8:59am (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Extra usage",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Extra usage not enabled • /extra-usage to enable",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a useful error when the Claude CLI panel reports a usage load failure", () => {
|
||||
expect(() => parseClaudeCliUsageText("Failed to load usage data")).toThrow(
|
||||
"Claude CLI could not load usage data. Open the CLI and retry `/usage`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readCodexAuthInfo / readCodexToken — filesystem paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readCodexAuthInfo", () => {
|
||||
const savedEnv = process.env.CODEX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = savedEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when auth.json does not exist", async () => {
|
||||
process.env.CODEX_HOME = "/tmp/__no_such_paperclip_codex_dir__";
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), "{bad json"),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns null when accessToken is absent", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({ accountId: "acc-1" })),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("reads the legacy flat auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
const auth = { accessToken: "codex-token", accountId: "acc-123" };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toMatchObject({
|
||||
accessToken: "codex-token",
|
||||
accountId: "acc-123",
|
||||
email: null,
|
||||
planType: null,
|
||||
});
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("reads the modern nested auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
const jwtPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
email: "codex@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_plan_type: "pro",
|
||||
chatgpt_user_email: "codex@example.com",
|
||||
},
|
||||
}),
|
||||
).toString("base64url");
|
||||
const auth = {
|
||||
tokens: {
|
||||
access_token: `header.${jwtPayload}.sig`,
|
||||
account_id: "acc-modern",
|
||||
refresh_token: "refresh-me",
|
||||
id_token: `header.${jwtPayload}.sig`,
|
||||
},
|
||||
last_refresh: "2026-03-14T12:00:00Z",
|
||||
};
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toMatchObject({
|
||||
accessToken: `header.${jwtPayload}.sig`,
|
||||
accountId: "acc-modern",
|
||||
refreshToken: "refresh-me",
|
||||
email: "codex@example.com",
|
||||
planType: "pro",
|
||||
lastRefresh: "2026-03-14T12:00:00Z",
|
||||
});
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCodexToken", () => {
|
||||
const savedEnv = process.env.CODEX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = savedEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns token and accountId from the nested auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "nested-token",
|
||||
account_id: "acc-nested",
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
expect(result).toEqual({ token: "nested-token", accountId: "acc-nested" });
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchClaudeQuota — response parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fetchClaudeQuota", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
it("throws when the API returns a non-200 status", async () => {
|
||||
mockFetch({}, false, 401);
|
||||
await expect(fetchClaudeQuota("token")).rejects.toThrow("anthropic usage api returned 401");
|
||||
});
|
||||
|
||||
it("returns an empty array when all window fields are absent", async () => {
|
||||
mockFetch({});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses five_hour window", async () => {
|
||||
mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current session",
|
||||
usedPercent: 40,
|
||||
resetsAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day window", async () => {
|
||||
mockFetch({ seven_day: { utilization: 0.75, resets_at: null } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 75,
|
||||
resetsAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day_sonnet and seven_day_opus windows", async () => {
|
||||
mockFetch({
|
||||
seven_day_sonnet: { utilization: 0.2, resets_at: null },
|
||||
seven_day_opus: { utilization: 0.9, resets_at: null },
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("Current week (Sonnet only)");
|
||||
expect(windows[1]!.label).toBe("Current week (Opus only)");
|
||||
});
|
||||
|
||||
it("sets usedPercent to null when utilization is absent", async () => {
|
||||
mockFetch({ five_hour: { resets_at: null } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows[0]!.usedPercent).toBe(null);
|
||||
});
|
||||
|
||||
it("includes all four windows when all are present", async () => {
|
||||
mockFetch({
|
||||
five_hour: { utilization: 0.1, resets_at: null },
|
||||
seven_day: { utilization: 0.2, resets_at: null },
|
||||
seven_day_sonnet: { utilization: 0.3, resets_at: null },
|
||||
seven_day_opus: { utilization: 0.4, resets_at: null },
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(4);
|
||||
const labels = windows.map((w: QuotaWindow) => w.label);
|
||||
expect(labels).toEqual([
|
||||
"Current session",
|
||||
"Current week (all models)",
|
||||
"Current week (Sonnet only)",
|
||||
"Current week (Opus only)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses extra usage when the OAuth response includes it", async () => {
|
||||
mockFetch({
|
||||
extra_usage: {
|
||||
is_enabled: false,
|
||||
utilization: null,
|
||||
},
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toEqual([
|
||||
{
|
||||
label: "Extra usage",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: "Not enabled",
|
||||
detail: "Extra usage not enabled",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchCodexQuota — response parsing (credits, windows)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fetchCodexQuota", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
it("throws when the WHAM API returns a non-200 status", async () => {
|
||||
mockFetch({}, false, 403);
|
||||
await expect(fetchCodexQuota("token", null)).rejects.toThrow("chatgpt wham api returned 403");
|
||||
});
|
||||
|
||||
it("passes ChatGPT-Account-Id header when accountId is provided", async () => {
|
||||
mockFetch({});
|
||||
await fetchCodexQuota("token", "acc-xyz");
|
||||
const callInit = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit;
|
||||
expect((callInit.headers as Record<string, string>)["ChatGPT-Account-Id"]).toBe("acc-xyz");
|
||||
});
|
||||
|
||||
it("omits ChatGPT-Account-Id header when accountId is null", async () => {
|
||||
mockFetch({});
|
||||
await fetchCodexQuota("token", null);
|
||||
const callInit = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit;
|
||||
expect((callInit.headers as Record<string, string>)["ChatGPT-Account-Id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns empty array when response body is empty", async () => {
|
||||
mockFetch({});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes numeric reset timestamps from WHAM", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: 1_767_312_000 },
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "5h limit", usedPercent: 30, resetsAt: "2026-01-02T00:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("parses secondary_window alongside primary_window", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10, limit_window_seconds: 18000 },
|
||||
secondary_window: { used_percent: 60, limit_window_seconds: 604800 },
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("5h limit");
|
||||
expect(windows[1]!.label).toBe("Weekly limit");
|
||||
});
|
||||
|
||||
it("includes Credits window when credits present and not unlimited", async () => {
|
||||
mockFetch({
|
||||
credits: { balance: 420, unlimited: false },
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "Credits", valueLabel: "$4.20 remaining", usedPercent: null });
|
||||
});
|
||||
|
||||
it("omits Credits window when unlimited is true", async () => {
|
||||
mockFetch({
|
||||
credits: { balance: 9999, unlimited: true },
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows 'N/A' valueLabel when credits balance is null", async () => {
|
||||
mockFetch({
|
||||
credits: { balance: null, unlimited: false },
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows[0]!.valueLabel).toBe("N/A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCodexRpcQuota", () => {
|
||||
it("maps account and model-specific Codex limits into quota windows", () => {
|
||||
const snapshot = mapCodexRpcQuota(
|
||||
{
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
primary: { usedPercent: 1, windowDurationMins: 300, resetsAt: 1_763_500_000 },
|
||||
secondary: { usedPercent: 27, windowDurationMins: 10_080 },
|
||||
planType: "pro",
|
||||
},
|
||||
rateLimitsByLimitId: {
|
||||
codex_bengalfox: {
|
||||
limitId: "codex_bengalfox",
|
||||
limitName: "GPT-5.3-Codex-Spark",
|
||||
primary: { usedPercent: 8, windowDurationMins: 300 },
|
||||
secondary: { usedPercent: 20, windowDurationMins: 10_080 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
account: {
|
||||
email: "codex@example.com",
|
||||
planType: "pro",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(snapshot.email).toBe("codex@example.com");
|
||||
expect(snapshot.planType).toBe("pro");
|
||||
expect(snapshot.windows).toEqual([
|
||||
{
|
||||
label: "5h limit",
|
||||
usedPercent: 1,
|
||||
resetsAt: "2025-11-18T21:06:40.000Z",
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "Weekly limit",
|
||||
usedPercent: 27,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "GPT-5.3-Codex-Spark · 5h limit",
|
||||
usedPercent: 8,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "GPT-5.3-Codex-Spark · Weekly limit",
|
||||
usedPercent: 20,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes a credits row when the root Codex limit reports finite credits", () => {
|
||||
const snapshot = mapCodexRpcQuota({
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
credits: {
|
||||
unlimited: false,
|
||||
balance: "12.34",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.windows).toEqual([
|
||||
{
|
||||
label: "Credits",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: "$12.34 remaining",
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchWithTimeout — abort on timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fetchWithTimeout", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves normally when fetch completes before timeout", async () => {
|
||||
const mockResponse = { ok: true, status: 200, json: async () => ({}) } as Response;
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockResponse));
|
||||
|
||||
const result = await fetchWithTimeout("https://example.com", {}, 5000);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects with abort error when fetch takes too long", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("The operation was aborted.", "AbortError"));
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const promise = fetchWithTimeout("https://example.com", {}, 1000);
|
||||
vi.advanceTimersByTime(1001);
|
||||
await expect(promise).rejects.toThrow("aborted");
|
||||
});
|
||||
});
|
||||
275
server/src/__tests__/routines-e2e.test.ts
Normal file
275
server/src/__tests__/routines-e2e.test.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
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,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
instanceSettings,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
routineRuns,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
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 };
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres routine route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-e2e-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 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 tempDb?.cleanup();
|
||||
});
|
||||
|
||||
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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
271
server/src/__tests__/routines-routes.test.ts
Normal file
271
server/src/__tests__/routines-routes.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
423
server/src/__tests__/routines-service.test.ts
Normal file
423
server/src/__tests__/routines-service.test.ts
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projects,
|
||||
routineRuns,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { routineService } from "../services/routines.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 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 tempDb?.cleanup();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
82
server/src/__tests__/ui-branding.test.ts
Normal file
82
server/src/__tests__/ui-branding.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyUiBranding,
|
||||
getWorktreeUiBranding,
|
||||
isWorktreeUiBrandingEnabled,
|
||||
renderFaviconLinks,
|
||||
renderRuntimeBrandingMeta,
|
||||
} from "../ui-branding.js";
|
||||
|
||||
const TEMPLATE = `<!doctype html>
|
||||
<head>
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
|
||||
<!-- PAPERCLIP_FAVICON_START -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<!-- PAPERCLIP_FAVICON_END -->
|
||||
</head>`;
|
||||
|
||||
describe("ui branding", () => {
|
||||
it("detects worktree mode from PAPERCLIP_IN_WORKTREE", () => {
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "true" })).toBe(true);
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "1" })).toBe(true);
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves name, color, and text color for worktree branding", () => {
|
||||
const branding = getWorktreeUiBranding({
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
});
|
||||
|
||||
expect(branding.enabled).toBe(true);
|
||||
expect(branding.name).toBe("paperclip-pr-432");
|
||||
expect(branding.color).toBe("#4f86f7");
|
||||
expect(branding.textColor).toMatch(/^#[0-9a-f]{6}$/);
|
||||
expect(branding.faviconHref).toContain("data:image/svg+xml,");
|
||||
});
|
||||
|
||||
it("renders a dynamic worktree favicon when enabled", () => {
|
||||
const links = renderFaviconLinks(
|
||||
getWorktreeUiBranding({
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
}),
|
||||
);
|
||||
expect(links).toContain("data:image/svg+xml,");
|
||||
expect(links).toContain('rel="shortcut icon"');
|
||||
});
|
||||
|
||||
it("renders runtime branding metadata for the ui", () => {
|
||||
const meta = renderRuntimeBrandingMeta(
|
||||
getWorktreeUiBranding({
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
}),
|
||||
);
|
||||
expect(meta).toContain('name="paperclip-worktree-name"');
|
||||
expect(meta).toContain('content="paperclip-pr-432"');
|
||||
expect(meta).toContain('name="paperclip-worktree-color"');
|
||||
});
|
||||
|
||||
it("rewrites the favicon and runtime branding blocks for worktree instances only", () => {
|
||||
const branded = applyUiBranding(TEMPLATE, {
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||
});
|
||||
expect(branded).toContain("data:image/svg+xml,");
|
||||
expect(branded).toContain('name="paperclip-worktree-name"');
|
||||
expect(branded).not.toContain('href="/favicon.svg"');
|
||||
|
||||
const defaultHtml = applyUiBranding(TEMPLATE, {});
|
||||
expect(defaultHtml).toContain('href="/favicon.svg"');
|
||||
expect(defaultHtml).not.toContain('name="paperclip-worktree-name"');
|
||||
});
|
||||
});
|
||||
95
server/src/__tests__/work-products.test.ts
Normal file
95
server/src/__tests__/work-products.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { workProductService } from "../services/work-products.ts";
|
||||
|
||||
function createWorkProductRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const now = new Date("2026-03-17T00:00:00.000Z");
|
||||
return {
|
||||
id: "work-product-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
issueId: "issue-1",
|
||||
executionWorkspaceId: null,
|
||||
runtimeServiceId: null,
|
||||
type: "pull_request",
|
||||
provider: "github",
|
||||
externalId: null,
|
||||
title: "PR 1",
|
||||
url: "https://example.com/pr/1",
|
||||
status: "open",
|
||||
reviewState: "draft",
|
||||
isPrimary: true,
|
||||
healthStatus: "unknown",
|
||||
summary: null,
|
||||
metadata: null,
|
||||
createdByRunId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workProductService", () => {
|
||||
it("uses a transaction when creating a new primary work product", async () => {
|
||||
const updatedWhere = vi.fn(async () => undefined);
|
||||
const updateSet = vi.fn(() => ({ where: updatedWhere }));
|
||||
const txUpdate = vi.fn(() => ({ set: updateSet }));
|
||||
|
||||
const insertedRow = createWorkProductRow();
|
||||
const insertReturning = vi.fn(async () => [insertedRow]);
|
||||
const insertValues = vi.fn(() => ({ returning: insertReturning }));
|
||||
const txInsert = vi.fn(() => ({ values: insertValues }));
|
||||
|
||||
const tx = {
|
||||
update: txUpdate,
|
||||
insert: txInsert,
|
||||
};
|
||||
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => await callback(tx));
|
||||
|
||||
const svc = workProductService({ transaction } as any);
|
||||
const result = await svc.createForIssue("issue-1", "company-1", {
|
||||
type: "pull_request",
|
||||
provider: "github",
|
||||
title: "PR 1",
|
||||
status: "open",
|
||||
reviewState: "draft",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(txInsert).toHaveBeenCalledTimes(1);
|
||||
expect(result?.id).toBe("work-product-1");
|
||||
});
|
||||
|
||||
it("uses a transaction when promoting an existing work product to primary", async () => {
|
||||
const existingRow = createWorkProductRow({ isPrimary: false });
|
||||
|
||||
const selectWhere = vi.fn(async () => [existingRow]);
|
||||
const selectFrom = vi.fn(() => ({ where: selectWhere }));
|
||||
const txSelect = vi.fn(() => ({ from: selectFrom }));
|
||||
|
||||
const updateReturning = vi
|
||||
.fn()
|
||||
.mockResolvedValue([createWorkProductRow({ reviewState: "ready_for_review" })]);
|
||||
const updateWhere = vi.fn(() => ({ returning: updateReturning }));
|
||||
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
||||
const txUpdate = vi.fn(() => ({ set: updateSet }));
|
||||
|
||||
const tx = {
|
||||
select: txSelect,
|
||||
update: txUpdate,
|
||||
};
|
||||
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => await callback(tx));
|
||||
|
||||
const svc = workProductService({ transaction } as any);
|
||||
const result = await svc.update("work-product-1", {
|
||||
isPrimary: true,
|
||||
reviewState: "ready_for_review",
|
||||
});
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txSelect).toHaveBeenCalledTimes(1);
|
||||
expect(txUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(result?.reviewState).toBe("ready_for_review");
|
||||
});
|
||||
});
|
||||
1596
server/src/__tests__/workspace-runtime.test.ts
Normal file
1596
server/src/__tests__/workspace-runtime.test.ts
Normal file
File diff suppressed because it is too large
Load diff
426
server/src/__tests__/worktree-config.test.ts
Normal file
426
server/src/__tests__/worktree-config.test.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyRuntimePortSelectionToConfig,
|
||||
maybePersistWorktreeRuntimePorts,
|
||||
maybeRepairLegacyWorktreeConfigAndEnvFiles,
|
||||
} from "../worktree-config.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function buildLegacyConfig(sharedRoot: string) {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-26T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres" as const,
|
||||
embeddedPostgresDataDir: path.join(sharedRoot, "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(sharedRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file" as const,
|
||||
logDir: path.join(sharedRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted" as const,
|
||||
exposure: "private" as const,
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "explicit" as const,
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk" as const,
|
||||
localDisk: {
|
||||
baseDir: path.join(sharedRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted" as const,
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(sharedRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("worktree config repair", () => {
|
||||
it("repairs legacy repo-local worktree config and env files into an isolated instance", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-884-ai-commits-component");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const envPath = path.join(paperclipDir, ".env");
|
||||
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
||||
await fs.writeFile(
|
||||
envPath,
|
||||
[
|
||||
"# Paperclip environment variables",
|
||||
"PAPERCLIP_IN_WORKTREE=true",
|
||||
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
|
||||
"PAPERCLIP_AGENT_JWT_SECRET=shared-secret",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_CONTEXT;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
|
||||
expect(result).toEqual({
|
||||
repairedConfig: true,
|
||||
repairedEnv: true,
|
||||
});
|
||||
|
||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
const repairedEnv = await fs.readFile(envPath, "utf8");
|
||||
const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
|
||||
|
||||
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
|
||||
expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
|
||||
expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs"));
|
||||
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
|
||||
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
||||
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
|
||||
expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"');
|
||||
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`);
|
||||
expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`);
|
||||
expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"');
|
||||
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
|
||||
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component");
|
||||
});
|
||||
|
||||
it("avoids sibling worktree ports when repairing legacy configs", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const envPath = path.join(paperclipDir, ".env");
|
||||
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.mkdir(siblingInstanceRoot, { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
||||
await fs.writeFile(
|
||||
envPath,
|
||||
[
|
||||
"# Paperclip environment variables",
|
||||
"PAPERCLIP_IN_WORKTREE=true",
|
||||
"PAPERCLIP_WORKTREE_NAME=PAP-880-thumbs-capture-for-evals-feature",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(siblingInstanceRoot, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(siblingInstanceRoot),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(siblingInstanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature";
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
|
||||
expect(result.repairedConfig).toBe(true);
|
||||
expect(repairedConfig.server.port).toBe(3102);
|
||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
||||
});
|
||||
|
||||
it("rebalances duplicate ports for already isolated worktree configs", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-"));
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const repoWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees");
|
||||
const siblingWorktreeRoot = path.join(repoWorktreesRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
||||
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
||||
const currentWorktreeRoot = path.join(repoWorktreesRoot, "PAP-884-ai-commits-component");
|
||||
const paperclipDir = path.join(currentWorktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const envPath = path.join(paperclipDir, ".env");
|
||||
const currentInstanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
|
||||
const siblingConfigPath = path.join(siblingWorktreeRoot, ".paperclip", "config.json");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.mkdir(path.dirname(siblingConfigPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(currentInstanceRoot),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(currentInstanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(currentInstanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(currentInstanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(currentInstanceRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(currentInstanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
envPath,
|
||||
[
|
||||
"# Paperclip environment variables",
|
||||
"PAPERCLIP_IN_WORKTREE=true",
|
||||
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
siblingConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(siblingInstanceRoot),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(siblingInstanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(currentWorktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
|
||||
expect(result.repairedConfig).toBe(true);
|
||||
expect(repairedConfig.server.port).toBe(3102);
|
||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
||||
});
|
||||
|
||||
it("persists runtime-selected worktree ports back into config", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const instanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(instanceRoot),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||
embeddedPostgresPort: 54331,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(instanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(instanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(instanceRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox";
|
||||
process.env.PAPERCLIP_HOME = isolatedHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox";
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
|
||||
maybePersistWorktreeRuntimePorts({
|
||||
serverPort: 3103,
|
||||
databasePort: 54335,
|
||||
});
|
||||
|
||||
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
|
||||
expect(writtenConfig.server.port).toBe(3103);
|
||||
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
|
||||
expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/");
|
||||
});
|
||||
|
||||
it("can update the in-memory config without rewriting env-driven ports", () => {
|
||||
const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), {
|
||||
serverPort: 3104,
|
||||
databasePort: 54340,
|
||||
allowServerPortWrite: false,
|
||||
allowDatabasePortWrite: true,
|
||||
});
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(config.server.port).toBe(3100);
|
||||
expect(config.database.embeddedPostgresPort).toBe(54340);
|
||||
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js";
|
||||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js";
|
||||
export type {
|
||||
ServerAdapterModule,
|
||||
AdapterExecutionContext,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
buildInvocationEnvForLogs,
|
||||
ensurePathInEnv,
|
||||
resolveCommandForLogs,
|
||||
runChildProcess,
|
||||
} from "../utils.js";
|
||||
|
||||
|
|
@ -21,6 +23,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 15);
|
||||
|
|
@ -28,10 +37,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "process",
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
cwd,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
env: loggedEnv,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,43 @@
|
|||
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,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
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,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
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,
|
||||
|
|
@ -39,6 +58,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,
|
||||
|
|
@ -46,6 +67,18 @@ import {
|
|||
import {
|
||||
agentConfigurationDoc as piAgentConfigurationDoc,
|
||||
} from "@paperclipai/adapter-pi-local";
|
||||
import {
|
||||
execute as hermesExecute,
|
||||
testEnvironment as hermesTestEnvironment,
|
||||
sessionCodec as hermesSessionCodec,
|
||||
listSkills as hermesListSkills,
|
||||
syncSkills as hermesSyncSkills,
|
||||
detectModel as detectModelFromHermes,
|
||||
} from "hermes-paperclip-adapter/server";
|
||||
import {
|
||||
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||
models as hermesModels,
|
||||
} from "hermes-paperclip-adapter";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
|
||||
|
|
@ -53,34 +86,58 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
|||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
listSkills: listClaudeSkills,
|
||||
syncSkills: syncClaudeSkills,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined,
|
||||
models: claudeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: claudeAgentConfigurationDoc,
|
||||
getQuotaWindows: claudeGetQuotaWindows,
|
||||
};
|
||||
|
||||
const codexLocalAdapter: ServerAdapterModule = {
|
||||
type: "codex_local",
|
||||
execute: codexExecute,
|
||||
testEnvironment: codexTestEnvironment,
|
||||
listSkills: listCodexSkills,
|
||||
syncSkills: syncCodexSkills,
|
||||
sessionCodec: codexSessionCodec,
|
||||
sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined,
|
||||
models: codexModels,
|
||||
listModels: listCodexModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||
getQuotaWindows: codexGetQuotaWindows,
|
||||
};
|
||||
|
||||
const cursorLocalAdapter: ServerAdapterModule = {
|
||||
type: "cursor",
|
||||
execute: cursorExecute,
|
||||
testEnvironment: cursorTestEnvironment,
|
||||
listSkills: listCursorSkills,
|
||||
syncSkills: syncCursorSkills,
|
||||
sessionCodec: cursorSessionCodec,
|
||||
sessionManagement: getAdapterSessionManagement("cursor") ?? undefined,
|
||||
models: cursorModels,
|
||||
listModels: listCursorModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const geminiLocalAdapter: ServerAdapterModule = {
|
||||
type: "gemini_local",
|
||||
execute: geminiExecute,
|
||||
testEnvironment: geminiTestEnvironment,
|
||||
listSkills: listGeminiSkills,
|
||||
syncSkills: syncGeminiSkills,
|
||||
sessionCodec: geminiSessionCodec,
|
||||
sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined,
|
||||
models: geminiModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: geminiAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
execute: openclawGatewayExecute,
|
||||
|
|
@ -94,8 +151,11 @@ const openCodeLocalAdapter: ServerAdapterModule = {
|
|||
type: "opencode_local",
|
||||
execute: openCodeExecute,
|
||||
testEnvironment: openCodeTestEnvironment,
|
||||
listSkills: listOpenCodeSkills,
|
||||
syncSkills: syncOpenCodeSkills,
|
||||
sessionCodec: openCodeSessionCodec,
|
||||
models: openCodeModels,
|
||||
sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined,
|
||||
listModels: listOpenCodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: openCodeAgentConfigurationDoc,
|
||||
|
|
@ -105,13 +165,29 @@ const piLocalAdapter: ServerAdapterModule = {
|
|||
type: "pi_local",
|
||||
execute: piExecute,
|
||||
testEnvironment: piTestEnvironment,
|
||||
listSkills: listPiSkills,
|
||||
syncSkills: syncPiSkills,
|
||||
sessionCodec: piSessionCodec,
|
||||
sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined,
|
||||
models: [],
|
||||
listModels: listPiModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: piAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const hermesLocalAdapter: ServerAdapterModule = {
|
||||
type: "hermes_local",
|
||||
execute: hermesExecute,
|
||||
testEnvironment: hermesTestEnvironment,
|
||||
sessionCodec: hermesSessionCodec,
|
||||
listSkills: hermesListSkills,
|
||||
syncSkills: hermesSyncSkills,
|
||||
models: hermesModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
||||
detectModel: () => detectModelFromHermes(),
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[
|
||||
claudeLocalAdapter,
|
||||
|
|
@ -119,7 +195,9 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
|||
openCodeLocalAdapter,
|
||||
piLocalAdapter,
|
||||
cursorLocalAdapter,
|
||||
geminiLocalAdapter,
|
||||
openclawGatewayAdapter,
|
||||
hermesLocalAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
|
|
@ -148,6 +226,15 @@ export function listServerAdapters(): ServerAdapterModule[] {
|
|||
return Array.from(adaptersByType.values());
|
||||
}
|
||||
|
||||
export async function detectAdapterModel(
|
||||
type: string,
|
||||
): Promise<{ model: string; provider: string; source: string } | null> {
|
||||
const adapter = adaptersByType.get(type);
|
||||
if (!adapter?.detectModel) return null;
|
||||
const detected = await adapter.detectModel();
|
||||
return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null;
|
||||
}
|
||||
|
||||
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
||||
return adaptersByType.get(type) ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// imports (process/, http/, heartbeat.ts) don't need rewriting.
|
||||
export type {
|
||||
AdapterAgent,
|
||||
AdapterSessionManagement,
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterExecutionResult,
|
||||
|
|
@ -13,7 +14,16 @@ export type {
|
|||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSkillSyncMode,
|
||||
AdapterSkillState,
|
||||
AdapterSkillOrigin,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
AdapterSkillContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
NativeContextManagement,
|
||||
ResolvedSessionCompactionPolicy,
|
||||
SessionCompactionPolicy,
|
||||
ServerAdapterModule,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
|
|
|||
|
|
@ -1,32 +1,78 @@
|
|||
// Re-export everything from the shared adapter-utils/server-utils package.
|
||||
// This file is kept as a convenience shim so existing in-tree
|
||||
// imports (process/, http/, heartbeat.ts) don't need rewriting.
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
export {
|
||||
type RunProcessResult,
|
||||
runningProcesses,
|
||||
MAX_CAPTURE_BYTES,
|
||||
MAX_EXCERPT_BYTES,
|
||||
parseObject,
|
||||
asString,
|
||||
asNumber,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
parseJson,
|
||||
appendWithCap,
|
||||
resolvePathValue,
|
||||
renderTemplate,
|
||||
redactEnvForLogs,
|
||||
buildPaperclipEnv,
|
||||
defaultPathForPlatform,
|
||||
ensurePathInEnv,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import * as serverUtils from "@paperclipai/adapter-utils/server-utils";
|
||||
export type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
type BuildInvocationEnvForLogsOptions = {
|
||||
runtimeEnv?: NodeJS.ProcessEnv | Record<string, string>;
|
||||
includeRuntimeKeys?: string[];
|
||||
resolvedCommand?: string | null;
|
||||
resolvedCommandEnvKey?: string;
|
||||
};
|
||||
|
||||
export const runningProcesses: Map<string, { child: ChildProcess; graceSec: number }> =
|
||||
serverUtils.runningProcesses;
|
||||
export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES;
|
||||
export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES;
|
||||
export const parseObject = serverUtils.parseObject;
|
||||
export const asString = serverUtils.asString;
|
||||
export const asNumber = serverUtils.asNumber;
|
||||
export const asBoolean = serverUtils.asBoolean;
|
||||
export const asStringArray = serverUtils.asStringArray;
|
||||
export const parseJson = serverUtils.parseJson;
|
||||
export const appendWithCap = serverUtils.appendWithCap;
|
||||
export const resolvePathValue = serverUtils.resolvePathValue;
|
||||
export const renderTemplate = serverUtils.renderTemplate;
|
||||
export const redactEnvForLogs = serverUtils.redactEnvForLogs;
|
||||
export const buildPaperclipEnv = serverUtils.buildPaperclipEnv;
|
||||
export const defaultPathForPlatform = serverUtils.defaultPathForPlatform;
|
||||
export const ensurePathInEnv = serverUtils.ensurePathInEnv;
|
||||
export const ensureAbsoluteDirectory = serverUtils.ensureAbsoluteDirectory;
|
||||
export const ensureCommandResolvable = serverUtils.ensureCommandResolvable;
|
||||
export const resolveCommandForLogs = serverUtils.resolveCommandForLogs;
|
||||
|
||||
export function buildInvocationEnvForLogs(
|
||||
env: Record<string, string>,
|
||||
options: BuildInvocationEnvForLogsOptions = {},
|
||||
): Record<string, string> {
|
||||
// TODO: Remove this fallback once @paperclipai/adapter-utils exports buildInvocationEnvForLogs everywhere we consume it.
|
||||
const maybeBuildInvocationEnvForLogs = (
|
||||
serverUtils as typeof serverUtils & {
|
||||
buildInvocationEnvForLogs?: (
|
||||
env: Record<string, string>,
|
||||
options?: BuildInvocationEnvForLogsOptions,
|
||||
) => Record<string, string>;
|
||||
}
|
||||
).buildInvocationEnvForLogs;
|
||||
|
||||
if (typeof maybeBuildInvocationEnvForLogs === "function") {
|
||||
return maybeBuildInvocationEnvForLogs(env, options);
|
||||
}
|
||||
|
||||
const merged: Record<string, string> = { ...env };
|
||||
const runtimeEnv = options.runtimeEnv ?? {};
|
||||
|
||||
for (const key of options.includeRuntimeKeys ?? []) {
|
||||
if (key in merged) continue;
|
||||
const value = runtimeEnv[key];
|
||||
if (typeof value !== "string" || value.length === 0) continue;
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
const resolvedCommand = options.resolvedCommand?.trim();
|
||||
if (resolvedCommand) {
|
||||
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand;
|
||||
}
|
||||
|
||||
return redactEnvForLogs(merged);
|
||||
}
|
||||
|
||||
// Re-export runChildProcess with the server's pino logger wired in.
|
||||
import { runChildProcess as _runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
|
||||
const _runChildProcess = serverUtils.runChildProcess;
|
||||
|
||||
export async function runChildProcess(
|
||||
runId: string,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ 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";
|
||||
import { secretRoutes } from "./routes/secrets.js";
|
||||
|
|
@ -21,17 +24,44 @@ import { costRoutes } from "./routes/costs.js";
|
|||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js";
|
||||
import { createPluginWorkerManager } from "./services/plugin-worker-manager.js";
|
||||
import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
|
||||
import { pluginJobStore } from "./services/plugin-job-store.js";
|
||||
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
||||
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
|
||||
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
|
||||
import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js";
|
||||
import { createPluginEventBus } from "./services/plugin-event-bus.js";
|
||||
import { setPluginEventBus } from "./services/activity-log.js";
|
||||
import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js";
|
||||
import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js";
|
||||
import { pluginRegistryService } from "./services/plugin-registry.js";
|
||||
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
|
||||
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
||||
|
||||
type UiMode = "none" | "static" | "vite-dev";
|
||||
|
||||
export function resolveViteHmrPort(serverPort: number): number {
|
||||
if (serverPort <= 55_535) {
|
||||
return serverPort + 10_000;
|
||||
}
|
||||
return Math.max(1_024, serverPort - 10_000);
|
||||
}
|
||||
|
||||
export async function createApp(
|
||||
db: Db,
|
||||
opts: {
|
||||
uiMode: UiMode;
|
||||
serverPort: number;
|
||||
storageService: StorageService;
|
||||
deploymentMode: DeploymentMode;
|
||||
deploymentExposure: DeploymentExposure;
|
||||
|
|
@ -39,13 +69,22 @@ export async function createApp(
|
|||
bindHost: string;
|
||||
authReady: boolean;
|
||||
companyDeletionEnabled: boolean;
|
||||
instanceId?: string;
|
||||
hostVersion?: string;
|
||||
localPluginDir?: string;
|
||||
betterAuthHandler?: express.RequestHandler;
|
||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||
},
|
||||
) {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
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;
|
||||
},
|
||||
}));
|
||||
app.use(httpLogger);
|
||||
const privateHostnameGateEnabled =
|
||||
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";
|
||||
|
|
@ -100,11 +139,14 @@ 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));
|
||||
api.use(secretRoutes(db));
|
||||
|
|
@ -112,6 +154,70 @@ export async function createApp(
|
|||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(instanceSettingsRoutes(db));
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = createPluginWorkerManager();
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
const eventBus = createPluginEventBus();
|
||||
setPluginEventBus(eventBus);
|
||||
const jobStore = pluginJobStore(db);
|
||||
const lifecycle = pluginLifecycleManager(db, { workerManager });
|
||||
const scheduler = createPluginJobScheduler({
|
||||
db,
|
||||
jobStore,
|
||||
workerManager,
|
||||
});
|
||||
const toolDispatcher = createPluginToolDispatcher({
|
||||
workerManager,
|
||||
lifecycleManager: lifecycle,
|
||||
db,
|
||||
});
|
||||
const jobCoordinator = createPluginJobCoordinator({
|
||||
db,
|
||||
lifecycle,
|
||||
scheduler,
|
||||
jobStore,
|
||||
});
|
||||
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
|
||||
const loader = pluginLoader(
|
||||
db,
|
||||
{ localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR },
|
||||
{
|
||||
workerManager,
|
||||
eventBus,
|
||||
jobScheduler: scheduler,
|
||||
jobStore,
|
||||
toolDispatcher,
|
||||
lifecycleManager: lifecycle,
|
||||
instanceInfo: {
|
||||
instanceId: opts.instanceId ?? "default",
|
||||
hostVersion: opts.hostVersion ?? "0.0.0",
|
||||
},
|
||||
buildHostHandlers: (pluginId, manifest) => {
|
||||
const notifyWorker = (method: string, params: unknown) => {
|
||||
const handle = workerManager.getWorker(pluginId);
|
||||
if (handle) handle.notify(method, params);
|
||||
};
|
||||
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker);
|
||||
hostServicesDisposers.set(pluginId, () => services.dispose());
|
||||
return createHostClientHandlers({
|
||||
pluginId,
|
||||
capabilities: manifest.capabilities,
|
||||
services,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
api.use(
|
||||
pluginRoutes(
|
||||
db,
|
||||
loader,
|
||||
{ scheduler, jobStore },
|
||||
{ workerManager },
|
||||
{ toolDispatcher },
|
||||
{ workerManager },
|
||||
),
|
||||
);
|
||||
api.use(
|
||||
accessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
|
|
@ -124,6 +230,9 @@ export async function createApp(
|
|||
app.use("/api", (_req, res) => {
|
||||
res.status(404).json({ error: "API route not found" });
|
||||
});
|
||||
app.use(pluginUiStaticRoutes(db, {
|
||||
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||
}));
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
if (opts.uiMode === "static") {
|
||||
|
|
@ -134,7 +243,7 @@ export async function createApp(
|
|||
];
|
||||
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
||||
if (uiDist) {
|
||||
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
|
||||
const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
|
||||
app.use(express.static(uiDist));
|
||||
app.get(/.*/, (_req, res) => {
|
||||
res.status(200).set("Content-Type", "text/html").end(indexHtml);
|
||||
|
|
@ -146,12 +255,18 @@ export async function createApp(
|
|||
|
||||
if (opts.uiMode === "vite-dev") {
|
||||
const uiRoot = path.resolve(__dirname, "../../ui");
|
||||
const hmrPort = resolveViteHmrPort(opts.serverPort);
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const vite = await createViteServer({
|
||||
root: uiRoot,
|
||||
appType: "spa",
|
||||
appType: "custom",
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
host: opts.bindHost,
|
||||
port: hmrPort,
|
||||
clientPort: hmrPort,
|
||||
},
|
||||
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
|
||||
},
|
||||
});
|
||||
|
|
@ -161,7 +276,7 @@ export async function createApp(
|
|||
try {
|
||||
const templatePath = path.resolve(uiRoot, "index.html");
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
const html = await vite.transformIndexHtml(req.originalUrl, template);
|
||||
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(html);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
@ -171,5 +286,35 @@ export async function createApp(
|
|||
|
||||
app.use(errorHandler);
|
||||
|
||||
jobCoordinator.start();
|
||||
scheduler.start();
|
||||
void toolDispatcher.initialize().catch((err) => {
|
||||
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
|
||||
});
|
||||
const devWatcher = opts.uiMode === "vite-dev"
|
||||
? createPluginDevWatcher(
|
||||
lifecycle,
|
||||
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
|
||||
)
|
||||
: null;
|
||||
void loader.loadAll().then((result) => {
|
||||
if (!result) return;
|
||||
for (const loaded of result.results) {
|
||||
if (devWatcher && loaded.success && loaded.plugin.packagePath) {
|
||||
devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath);
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.error({ err }, "Failed to load ready plugins on startup");
|
||||
});
|
||||
process.once("exit", () => {
|
||||
devWatcher?.close();
|
||||
hostServiceCleanup.disposeAll();
|
||||
hostServiceCleanup.teardown();
|
||||
});
|
||||
process.once("beforeExit", () => {
|
||||
void flushPluginLogBuffer();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
74
server/src/attachment-types.ts
Normal file
74
server/src/attachment-types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Shared attachment content-type configuration.
|
||||
*
|
||||
* By default only image types are allowed. Set the
|
||||
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
|
||||
* comma-separated list of MIME types or wildcard patterns to expand the
|
||||
* allowed set.
|
||||
*
|
||||
* Examples:
|
||||
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
|
||||
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/*
|
||||
*
|
||||
* Supported pattern syntax:
|
||||
* - Exact types: "application/pdf"
|
||||
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
|
||||
*/
|
||||
|
||||
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"application/pdf",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
"application/json",
|
||||
"text/csv",
|
||||
"text/html",
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a comma-separated list of MIME type patterns into a normalised array.
|
||||
* Returns the default image-only list when the input is empty or undefined.
|
||||
*/
|
||||
export function parseAllowedTypes(raw: string | undefined): string[] {
|
||||
if (!raw) return [...DEFAULT_ALLOWED_TYPES];
|
||||
const parsed = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length > 0);
|
||||
return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `contentType` matches any entry in `allowedPatterns`.
|
||||
*
|
||||
* Supports exact matches ("application/pdf") and wildcard / prefix
|
||||
* patterns ("image/*", "application/vnd.openxmlformats-officedocument.*").
|
||||
*/
|
||||
export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean {
|
||||
const ct = contentType.toLowerCase();
|
||||
return allowedPatterns.some((pattern) => {
|
||||
if (pattern === "*") return true;
|
||||
if (pattern.endsWith("/*") || pattern.endsWith(".*")) {
|
||||
return ct.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return ct === pattern;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Module-level singletons read once at startup ----------
|
||||
|
||||
const allowedPatterns: string[] = parseAllowedTypes(
|
||||
process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES,
|
||||
);
|
||||
|
||||
/** Convenience wrapper using the process-level allowed list. */
|
||||
export function isAllowedContentType(contentType: string): boolean {
|
||||
return matchesContentType(contentType, allowedPatterns);
|
||||
}
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES =
|
||||
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
|
|
@ -70,6 +70,9 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins?
|
|||
const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret";
|
||||
const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config);
|
||||
|
||||
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl;
|
||||
const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false;
|
||||
|
||||
const authConfig = {
|
||||
baseURL: baseUrl,
|
||||
secret,
|
||||
|
|
@ -88,6 +91,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins?
|
|||
requireEmailVerification: false,
|
||||
disableSignUp: config.authDisableSignUp,
|
||||
},
|
||||
...(isHttpOnly ? { advanced: { useSecureCookies: false } } : {}),
|
||||
};
|
||||
|
||||
if (!baseUrl) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { readConfigFile } from "./config-file.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { resolvePaperclipEnvPath } from "./paths.js";
|
||||
import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js";
|
||||
import {
|
||||
AUTH_BASE_URL_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
|
|
@ -27,6 +29,16 @@ if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
|
|||
loadDotenv({ path: PAPERCLIP_ENV_FILE_PATH, override: false, quiet: true });
|
||||
}
|
||||
|
||||
const CWD_ENV_PATH = resolve(process.cwd(), ".env");
|
||||
const isSameFile = existsSync(CWD_ENV_PATH) && existsSync(PAPERCLIP_ENV_FILE_PATH)
|
||||
? realpathSync(CWD_ENV_PATH) === realpathSync(PAPERCLIP_ENV_FILE_PATH)
|
||||
: CWD_ENV_PATH === PAPERCLIP_ENV_FILE_PATH;
|
||||
if (!isSameFile && existsSync(CWD_ENV_PATH)) {
|
||||
loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true });
|
||||
}
|
||||
|
||||
maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
|
||||
type DatabaseMode = "embedded-postgres" | "postgres";
|
||||
|
||||
export interface Config {
|
||||
|
|
|
|||
103
server/src/dev-server-status.ts
Normal file
103
server/src/dev-server-status.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
server/src/dev-watch-ignore.ts
Normal file
36
server/src/dev-watch-ignore.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function toGlobstarPath(candidate: string): string {
|
||||
return `${candidate.replaceAll(path.sep, "/")}/**`;
|
||||
}
|
||||
|
||||
function addIgnorePath(target: Set<string>, candidate: string): void {
|
||||
target.add(candidate);
|
||||
target.add(toGlobstarPath(candidate));
|
||||
try {
|
||||
const realPath = fs.realpathSync(candidate);
|
||||
target.add(realPath);
|
||||
target.add(toGlobstarPath(realPath));
|
||||
} catch {
|
||||
// Ignore paths that do not exist in the current checkout.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] {
|
||||
const ignorePaths = new Set<string>([
|
||||
"**/{node_modules,bower_components,vendor}/**",
|
||||
"**/.vite-temp/**",
|
||||
]);
|
||||
|
||||
for (const relativePath of [
|
||||
"../ui/node_modules",
|
||||
"../ui/node_modules/.vite-temp",
|
||||
"../ui/.vite",
|
||||
"../ui/dist",
|
||||
]) {
|
||||
addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath));
|
||||
}
|
||||
|
||||
return [...ignorePaths];
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
const FRIENDLY_PATH_SEGMENT_RE = /[^a-zA-Z0-9._-]+/g;
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
|
|
@ -61,6 +62,34 @@ export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
|
|||
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", trimmed);
|
||||
}
|
||||
|
||||
function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback = "_default"): string {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!trimmed) return fallback;
|
||||
const sanitized = trimmed
|
||||
.replace(FRIENDLY_PATH_SEGMENT_RE, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
export function resolveManagedProjectWorkspaceDir(input: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
repoName?: string | null;
|
||||
}): string {
|
||||
const companyId = input.companyId.trim();
|
||||
const projectId = input.projectId.trim();
|
||||
if (!companyId || !projectId) {
|
||||
throw new Error("Managed project workspace path requires companyId and projectId.");
|
||||
}
|
||||
return path.resolve(
|
||||
resolvePaperclipInstanceRoot(),
|
||||
"projects",
|
||||
sanitizeFriendlyPathSegment(companyId, "company"),
|
||||
sanitizeFriendlyPathSegment(projectId, "project"),
|
||||
sanitizeFriendlyPathSegment(input.repoName, "_default"),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
|
|
|
|||
1202
server/src/index.ts
1202
server/src/index.ts
File diff suppressed because it is too large
Load diff
146
server/src/log-redaction.ts
Normal file
146
server/src/log-redaction.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import os from "node:os";
|
||||
|
||||
export const CURRENT_USER_REDACTION_TOKEN = "*";
|
||||
|
||||
export interface CurrentUserRedactionOptions {
|
||||
enabled?: boolean;
|
||||
replacement?: string;
|
||||
userNames?: string[];
|
||||
homeDirs?: string[];
|
||||
}
|
||||
|
||||
type CurrentUserCandidates = {
|
||||
userNames: string[];
|
||||
homeDirs: string[];
|
||||
replacement: string;
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: Array<string | null | undefined>) {
|
||||
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
|
||||
}
|
||||
|
||||
function splitPathSegments(value: string) {
|
||||
return value.replace(/[\\/]+$/, "").split(/[\\/]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function replaceLastPathSegment(pathValue: string, replacement: string) {
|
||||
const normalized = pathValue.replace(/[\\/]+$/, "");
|
||||
const lastSeparator = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
|
||||
if (lastSeparator < 0) return replacement;
|
||||
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,
|
||||
process.env.LOGNAME,
|
||||
process.env.USERNAME,
|
||||
];
|
||||
|
||||
try {
|
||||
candidates.push(os.userInfo().username);
|
||||
} catch {
|
||||
// Some environments do not expose userInfo; env vars are enough fallback.
|
||||
}
|
||||
|
||||
return uniqueNonEmpty(candidates);
|
||||
}
|
||||
|
||||
function defaultHomeDirs(userNames: string[]) {
|
||||
const candidates: Array<string | null | undefined> = [
|
||||
process.env.HOME,
|
||||
process.env.USERPROFILE,
|
||||
];
|
||||
|
||||
try {
|
||||
candidates.push(os.homedir());
|
||||
} catch {
|
||||
// Ignore and fall back to env hints below.
|
||||
}
|
||||
|
||||
for (const userName of userNames) {
|
||||
candidates.push(`/Users/${userName}`);
|
||||
candidates.push(`/home/${userName}`);
|
||||
candidates.push(`C:\\Users\\${userName}`);
|
||||
}
|
||||
|
||||
return uniqueNonEmpty(candidates);
|
||||
}
|
||||
|
||||
let cachedCurrentUserCandidates: CurrentUserCandidates | null = null;
|
||||
|
||||
function getDefaultCurrentUserCandidates(): CurrentUserCandidates {
|
||||
if (cachedCurrentUserCandidates) return cachedCurrentUserCandidates;
|
||||
const userNames = defaultUserNames();
|
||||
cachedCurrentUserCandidates = {
|
||||
userNames,
|
||||
homeDirs: defaultHomeDirs(userNames),
|
||||
replacement: CURRENT_USER_REDACTION_TOKEN,
|
||||
};
|
||||
return cachedCurrentUserCandidates;
|
||||
}
|
||||
|
||||
function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) {
|
||||
const defaults = getDefaultCurrentUserCandidates();
|
||||
const userNames = uniqueNonEmpty(opts?.userNames ?? defaults.userNames);
|
||||
const homeDirs = uniqueNonEmpty(opts?.homeDirs ?? defaults.homeDirs);
|
||||
const replacement = opts?.replacement?.trim() || defaults.replacement;
|
||||
return { userNames, homeDirs, replacement };
|
||||
}
|
||||
|
||||
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 = 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, maskUserNameForLogs(userName, replacement));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function redactCurrentUserValue<T>(value: T, opts?: CurrentUserRedactionOptions): T {
|
||||
if (typeof value === "string") {
|
||||
return redactCurrentUserText(value, opts) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactCurrentUserValue(entry, opts)) as T;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = redactCurrentUserValue(entry, opts);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ function parseOrigin(value: string | undefined) {
|
|||
|
||||
function trustedOriginsForRequest(req: Request) {
|
||||
const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase()));
|
||||
const host = req.header("host")?.trim();
|
||||
const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = forwardedHost || req.header("host")?.trim();
|
||||
if (host) {
|
||||
origins.add(`http://${host}`.toLowerCase());
|
||||
origins.add(`https://${host}`.toLowerCase());
|
||||
|
|
@ -49,10 +50,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const logFile = path.join(logDir, "server.log");
|
|||
const sharedOpts = {
|
||||
translateTime: "HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
singleLine: true,
|
||||
};
|
||||
|
||||
export const logger = pino({
|
||||
|
|
|
|||
54
server/src/onboarding-assets/ceo/AGENTS.md
Normal file
54
server/src/onboarding-assets/ceo/AGENTS.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
You are the CEO. Your job is to lead the company, not to do individual contributor work. You own strategy, prioritization, and cross-functional coordination.
|
||||
|
||||
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.
|
||||
|
||||
## Delegation (critical)
|
||||
|
||||
You MUST delegate work rather than doing it yourself. When a task is assigned to you:
|
||||
|
||||
1. **Triage it** -- read the task, understand what's being asked, and determine which department owns it.
|
||||
2. **Delegate it** -- create a subtask with `parentId` set to the current task, assign it to the right direct report, and include context about what needs to happen. Use these routing rules:
|
||||
- **Code, bugs, features, infra, devtools, technical tasks** → CTO
|
||||
- **Marketing, content, social media, growth, devrel** → CMO
|
||||
- **UX, design, user research, design-system** → UXDesigner
|
||||
- **Cross-functional or unclear** → break into separate subtasks for each department, or assign to the CTO if it's primarily technical with a design component
|
||||
- If the right report doesn't exist yet, use the `paperclip-create-agent` skill to hire one before delegating.
|
||||
3. **Do NOT write code, implement features, or fix bugs yourself.** Your reports exist for this. Even if a task seems small or quick, delegate it.
|
||||
4. **Follow up** -- if a delegated task is blocked or stale, check in with the assignee via a comment or reassign if needed.
|
||||
|
||||
## What you DO personally
|
||||
|
||||
- Set priorities and make product decisions
|
||||
- Resolve cross-team conflicts or ambiguity
|
||||
- Communicate with the board (human users)
|
||||
- Approve or reject proposals from your reports
|
||||
- Hire new agents when the team needs capacity
|
||||
- Unblock your direct reports when they escalate to you
|
||||
|
||||
## Keeping work moving
|
||||
|
||||
- Don't let tasks sit idle. If you delegate something, check that it's progressing.
|
||||
- If a report is blocked, help unblock them -- escalate to the board if needed.
|
||||
- If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work.
|
||||
- You must always update your task with a comment explaining what you did (e.g., who you delegated to and why).
|
||||
|
||||
## 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
|
||||
72
server/src/onboarding-assets/ceo/HEARTBEAT.md
Normal file
72
server/src/onboarding-assets/ceo/HEARTBEAT.md
Normal 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`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue.
|
||||
- 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.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue