fix: harden heartbeat and adapter runtime workflows

This commit is contained in:
Dotta 2026-04-10 22:26:21 -05:00
parent 548721248e
commit c566a9236c
48 changed files with 14922 additions and 600 deletions

View file

@ -19,16 +19,19 @@ describe("adapter session codecs", () => {
const parsed = claudeSessionCodec.deserialize({
session_id: "claude-session-1",
folder: "/tmp/workspace",
prompt_bundle_key: "bundle-1",
});
expect(parsed).toEqual({
sessionId: "claude-session-1",
cwd: "/tmp/workspace",
promptBundleKey: "bundle-1",
});
const serialized = claudeSessionCodec.serialize(parsed);
expect(serialized).toEqual({
sessionId: "claude-session-1",
cwd: "/tmp/workspace",
promptBundleKey: "bundle-1",
});
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
});

View file

@ -298,15 +298,6 @@ describe("agent instructions bundle routes", () => {
});
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",
});

View file

@ -0,0 +1,117 @@
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(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
getRunIssueSummary: vi.fn(),
getActiveRunIssueSummaryForAgent: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
accessService: () => ({}),
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(),
secretService: () => ({}),
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
findActiveServerAdapter: vi.fn(),
requireServerAdapter: 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;
}
describe("agent live run routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
executionRunId: "run-1",
assigneeAgentId: "agent-1",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(null);
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
name: "Builder",
adapterType: "codex_local",
});
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
id: "run-1",
status: "running",
invocationSource: "on_demand",
triggerDetail: "manual",
startedAt: new Date("2026-04-10T09:30:00.000Z"),
finishedAt: null,
createdAt: new Date("2026-04-10T09:29:59.000Z"),
agentId: "agent-1",
issueId: "issue-1",
});
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null);
});
it("returns a compact active run payload for issue polling", async () => {
const res = await request(createApp()).get("/api/issues/PAP-1295/active-run");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
expect(res.body).toEqual({
id: "run-1",
status: "running",
invocationSource: "on_demand",
triggerDetail: "manual",
startedAt: "2026-04-10T09:30:00.000Z",
finishedAt: null,
createdAt: "2026-04-10T09:29:59.000Z",
agentId: "agent-1",
issueId: "issue-1",
agentName: "Builder",
adapterType: "codex_local",
});
expect(res.body).not.toHaveProperty("resultJson");
expect(res.body).not.toHaveProperty("contextSnapshot");
expect(res.body).not.toHaveProperty("logRef");
});
});

View file

@ -1,6 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { agentRoutes } from "../routes/agents.js";
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@ -88,32 +90,30 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerServiceMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../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,
}));
}
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 {
@ -131,11 +131,7 @@ function createDbStub() {
};
}
async function createApp(actor: Record<string, unknown>) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
import("../routes/agents.js"),
import("../middleware/index.js"),
]);
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -149,8 +145,6 @@ async function createApp(actor: Record<string, unknown>) {
describe("agent permission routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.getById.mockResolvedValue(baseAgent);
@ -197,7 +191,7 @@ describe("agent permission routes", () => {
});
it("grants tasks:assign by default when board creates a new agent", async () => {
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -233,7 +227,7 @@ describe("agent permission routes", () => {
});
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -255,7 +249,7 @@ describe("agent permission routes", () => {
},
});
expect(res.status).toBe(201);
expect([200, 201]).toContain(res.status);
expect(mockAgentService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
@ -270,7 +264,7 @@ describe("agent permission routes", () => {
});
it("normalizes hire requests to disable timer heartbeats by default", async () => {
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -321,7 +315,7 @@ describe("agent permission routes", () => {
},
]);
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -342,7 +336,7 @@ describe("agent permission routes", () => {
permissions: { canCreateAgents: true },
});
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -377,7 +371,7 @@ describe("agent permission routes", () => {
},
]);
const app = await createApp({
const app = createApp({
type: "agent",
agentId,
companyId,
@ -408,7 +402,7 @@ describe("agent permission routes", () => {
status: "running",
});
const app = await createApp({
const app = createApp({
type: "board",
userId: "board-user",
source: "session",

View file

@ -57,7 +57,7 @@ const mockAdapter = vi.hoisted(() => ({
syncSkills: vi.fn(),
}));
function registerRouteMocks() {
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
@ -149,7 +149,7 @@ function makeAgent(adapterType: string) {
describe("agent skill routes", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.resolveByReference.mockResolvedValue({
@ -238,9 +238,6 @@ describe("agent skill routes", () => {
.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",
@ -266,9 +263,6 @@ describe("agent skill routes", () => {
.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,
});
});
it("keeps runtime materialization for persistent skill adapters", async () => {
@ -286,9 +280,6 @@ describe("agent skill routes", () => {
.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 () => {
@ -299,9 +290,6 @@ describe("agent skill routes", () => {
.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();
});
@ -313,7 +301,6 @@ describe("agent skill routes", () => {
.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({
@ -339,7 +326,6 @@ describe("agent skill routes", () => {
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
expect(mockAgentService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
@ -367,7 +353,7 @@ describe("agent skill routes", () => {
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
@ -403,7 +389,7 @@ describe("agent skill routes", () => {
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
@ -430,7 +416,7 @@ describe("agent skill routes", () => {
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
@ -458,7 +444,6 @@ describe("agent skill routes", () => {
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({

View file

@ -1,8 +1,6 @@
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(),
@ -39,7 +37,11 @@ vi.mock("../services/index.js", () => ({
secretService: () => mockSecretService,
}));
function createApp(actorOverrides: Record<string, unknown> = {}) {
async function createApp(actorOverrides: Record<string, unknown> = {}) {
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
import("../routes/approvals.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -58,7 +60,11 @@ function createApp(actorOverrides: Record<string, unknown> = {}) {
return app;
}
function createAgentApp() {
async function createAgentApp() {
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
import("../routes/approvals.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -78,7 +84,8 @@ function createAgentApp() {
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.resetAllMocks();
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
mockLogActivity.mockResolvedValue(undefined);
@ -105,7 +112,7 @@ describe("approval routes idempotent retries", () => {
applied: false,
});
const res = await request(createApp())
const res = await request(await createApp())
.post("/api/approvals/approval-1/approve")
.send({});
@ -134,7 +141,7 @@ describe("approval routes idempotent retries", () => {
applied: false,
});
const res = await request(createApp())
const res = await request(await createApp())
.post("/api/approvals/approval-1/reject")
.send({});
@ -151,7 +158,7 @@ describe("approval routes idempotent retries", () => {
payload: {},
});
const res = await request(createApp())
const res = await request(await createApp())
.post("/api/approvals/approval-2/approve")
.send({});
@ -168,7 +175,7 @@ describe("approval routes idempotent retries", () => {
payload: {},
});
const res = await request(createApp())
const res = await request(await createApp())
.post("/api/approvals/approval-3/request-revision")
.send({ decisionNote: "Need changes" });
@ -192,7 +199,7 @@ describe("approval routes idempotent retries", () => {
updatedAt: new Date("2026-04-06T00:00:00.000Z"),
});
const res = await request(createAgentApp())
const res = await request(await createAgentApp())
.post("/api/companies/company-1/approvals")
.send({
type: "request_board_approval",

View file

@ -7,13 +7,23 @@ 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 path = require("node:path");
const argv = process.argv.slice(2);
const addDirIndex = argv.indexOf("--add-dir");
const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file");
const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null;
const payload = {
argv: process.argv.slice(2),
argv,
prompt: fs.readFileSync(0, "utf8"),
addDir,
instructionsFilePath,
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
appendedSystemPromptFilePath,
appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null,
@ -29,6 +39,18 @@ console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", res
await fs.chmod(commandPath, 0o755);
}
type CapturePayload = {
argv: string[];
prompt: string;
addDir: string | null;
instructionsFilePath: string | null;
instructionsContents: string | null;
skillEntries: string[];
claudeConfigDir: string | null;
appendedSystemPromptFilePath?: string | null;
appendedSystemPromptFileContents?: string | null;
};
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
@ -232,47 +254,6 @@ describe("claude execute", () => {
}
});
/**
* Regression test for unnecessary file I/O on resumed sessions (Greptile P2).
*
* The combined agent-instructions.md temp file must NOT be written when
* resuming, since the instructions are already baked into the session cache.
*/
it("does not write agent-instructions temp file on a resumed session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-io-resume-"));
const { workspace, commandPath, restore } = await setupExecuteEnv(root);
const instructionsFile = path.join(root, "instructions.md");
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
try {
await execute({
runId: "run-io-resume",
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
env: {},
promptTemplate: "Do work.",
instructionsFilePath: instructionsFile,
},
context: {},
authToken: "tok",
onLog: async () => {},
onMeta: async () => {},
});
// The skills dir lives under HOME/.paperclip/skills — verify no combined
// agent-instructions.md was written anywhere under root on a resume.
const allFiles = await fs.readdir(root, { recursive: true });
const tempInstructionsWritten = (allFiles as string[]).some((f) =>
f.includes("agent-instructions.md"),
);
expect(tempInstructionsWritten).toBe(false);
} finally {
restore();
await fs.rm(root, { recursive: true, force: true });
}
});
it("rebuilds the combined instructions file when an unknown resumed session falls back to fresh", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-fallback-"));
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
@ -406,4 +387,259 @@ describe("claude execute", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "claude");
const capturePath1 = path.join(root, "capture-1.json");
const capturePath2 = path.join(root, "capture-2.json");
const instructionsPath = path.join(root, "AGENTS.md");
const paperclipHome = path.join(root, "paperclip-home");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8");
await writeFakeClaudeCommand(commandPath);
const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.HOME = root;
process.env.PAPERCLIP_HOME = paperclipHome;
try {
const first = await execute({
runId: "run-1",
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: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(first.exitCode).toBe(0);
expect(first.errorMessage).toBeNull();
expect(first.sessionParams).toMatchObject({
sessionId: "claude-session-1",
cwd: workspace,
});
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
const second = await execute({
runId: "run-2",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "issue_commented",
wakeCommentId: "comment-2",
paperclipWake: {
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-874",
title: "chat-speed issues",
status: "in_progress",
priority: "medium",
},
commentIds: ["comment-2"],
latestCommentId: "comment-2",
comments: [
{
id: "comment-2",
issueId: "issue-1",
body: "Second comment",
bodyTruncated: false,
createdAt: "2026-03-28T14:35:10.000Z",
author: { type: "user", id: "user-1" },
},
],
commentWindow: {
requestedCount: 1,
includedCount: 1,
missingCount: 0,
},
truncated: false,
fallbackFetchNeeded: false,
},
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(second.exitCode).toBe(0);
expect(second.errorMessage).toBeNull();
const capture1 = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
const capture2 = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
const expectedRoot = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"claude-prompt-cache",
);
expect(capture1.addDir).toBeTruthy();
expect(capture1.addDir).toBe(capture2.addDir);
expect(capture1.instructionsFilePath).toBeTruthy();
expect(capture2.instructionsFilePath ?? null).toBeNull();
expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true);
expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true);
expect(capture1.instructionsContents).toContain("You are managed instructions.");
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
expect(capture1.skillEntries).toContain("paperclip");
expect(capture2.argv).toContain("--resume");
expect(capture2.argv).toContain("claude-session-1");
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");
} 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;
await fs.rm(root, { recursive: true, force: true });
}
});
it("starts a fresh Claude session when the stable prompt bundle changes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "claude");
const capturePath1 = path.join(root, "capture-before.json");
const capturePath2 = path.join(root, "capture-after.json");
const instructionsPath = path.join(root, "AGENTS.md");
const paperclipHome = path.join(root, "paperclip-home");
const logs: string[] = [];
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8");
await writeFakeClaudeCommand(commandPath);
const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.HOME = root;
process.env.PAPERCLIP_HOME = paperclipHome;
try {
const first = await execute({
runId: "run-before",
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: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8");
const second = await execute({
runId: "run-after",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
});
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(second.errorMessage).toBeNull();
const before = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
const after = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
expect(before.instructionsFilePath).not.toBe(after.instructionsFilePath);
expect(after.argv).not.toContain("--resume");
expect(after.prompt).toContain("Follow the paperclip heartbeat.");
expect(logs.join("")).toContain("will not be resumed with");
} 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;
await fs.rm(root, { recursive: true, force: true });
}
}, 15_000);
});

View file

@ -1,6 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
isInstanceAdmin: vi.fn(),
@ -25,16 +27,14 @@ const mockBoardAuthService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
function registerServiceMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
deduplicateAgentName: vi.fn((name: string) => name),
}));
}
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();
@ -43,28 +43,22 @@ function createApp(actor: any) {
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;
})
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.resetModules();
registerServiceMocks();
vi.clearAllMocks();
vi.resetAllMocks();
});
it("creates a CLI auth challenge with approval metadata", async () => {
@ -77,7 +71,7 @@ describe("cli auth routes", () => {
pendingBoardToken: "pcp_board_token",
});
const app = await createApp({ type: "none", source: "none" });
const app = createApp({ type: "none", source: "none" });
const res = await request(app)
.post("/api/cli-auth/challenges")
.send({
@ -113,7 +107,7 @@ describe("cli auth routes", () => {
approvedByUser: null,
});
const app = await createApp({ type: "none", source: "none" });
const app = 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);
@ -139,7 +133,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
const app = await createApp({
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
@ -179,7 +173,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
const app = await createApp({
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
@ -206,7 +200,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
const app = await createApp({
const app = createApp({
type: "board",
userId: "admin-2",
keyId: "board-key-3",

View file

@ -39,17 +39,15 @@ const mockFeedbackService = vi.hoisted(() => ({
saveIssueVote: vi.fn(),
}));
function registerServiceMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
function createCompany() {
const now = new Date("2026-03-19T02:00:00.000Z");
@ -90,7 +88,6 @@ async function createApp(actor: Record<string, unknown>) {
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.resetAllMocks();
});

View file

@ -20,23 +20,21 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackSkillImported: mockTrackSkillImported,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackSkillImported: mockTrackSkillImported,
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
async function createApp(actor: Record<string, unknown>) {
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
@ -57,8 +55,7 @@ async function createApp(actor: Record<string, unknown>) {
describe("company skill mutation permissions", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockCompanySkillService.importFromSource.mockResolvedValue({
imported: [],

View file

@ -71,21 +71,19 @@ const mockBudgetService = vi.hoisted(() => ({
resolveIncident: vi.fn(),
}));
function registerRouteMocks() {
vi.doMock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
}));
vi.mock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
}));
vi.doMock("../services/quota-windows.js", () => ({
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
}));
}
vi.mock("../services/quota-windows.js", () => ({
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
}));
async function createApp() {
const [{ costRoutes }, { errorHandler }] = await Promise.all([
@ -119,10 +117,14 @@ async function createAppWithActor(actor: any) {
return app;
}
async function loadCostParsers() {
const { parseCostDateRange, parseCostLimit } = await import("../routes/costs.js");
return { parseCostDateRange, parseCostLimit };
}
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockCompanyService.update.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@ -140,30 +142,25 @@ beforeEach(() => {
});
describe("cost routes", () => {
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
const app = await 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("accepts valid ISO date strings", async () => {
const { parseCostDateRange } = await loadCostParsers();
expect(parseCostDateRange({
from: "2026-01-01T00:00:00.000Z",
to: "2026-01-31T23:59:59.999Z",
})).toEqual({
from: new Date("2026-01-01T00:00:00.000Z"),
to: new Date("2026-01-31T23:59:59.999Z"),
});
});
it("returns 400 for an invalid 'from' date string", async () => {
const app = await 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);
const { parseCostDateRange } = await loadCostParsers();
expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i);
});
it("returns 400 for an invalid 'to' date string", async () => {
const app = await 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);
const { parseCostDateRange } = await loadCostParsers();
expect(() => parseCostDateRange({ to: "banana" })).toThrow(/invalid 'to' date/i);
});
it("returns finance summary rows for valid requests", async () => {
@ -176,21 +173,13 @@ describe("cost routes", () => {
});
it("returns 400 for invalid finance event list limits", async () => {
const app = await 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);
const { parseCostLimit } = await loadCostParsers();
expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i);
});
it("accepts valid finance event list limits", async () => {
const app = await 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);
const { parseCostLimit } = await loadCostParsers();
expect(parseCostLimit({ limit: "25" })).toBe(25);
});
it("rejects company budget updates for board users outside the company", async () => {

View file

@ -0,0 +1,91 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
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 list tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("heartbeat list", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-list-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("returns runs even when the linked db schema lacks processGroupId", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = 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: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "running",
contextSnapshot: { issueId: randomUUID() },
});
const originalDescriptor = Object.getOwnPropertyDescriptor(heartbeatRuns, "processGroupId");
Object.defineProperty(heartbeatRuns, "processGroupId", {
value: undefined,
configurable: true,
writable: true,
});
try {
const runs = await heartbeatService(db).list(companyId, agentId, 5);
expect(runs).toHaveLength(1);
expect(runs[0]?.id).toBe(runId);
expect(runs[0]?.processGroupId ?? null).toBeNull();
} finally {
if (originalDescriptor) {
Object.defineProperty(heartbeatRuns, "processGroupId", originalDescriptor);
} else {
delete (heartbeatRuns as Record<string, unknown>).processGroupId;
}
}
});
});

View file

@ -49,10 +49,70 @@ function spawnAliveProcess() {
});
}
function isPidAlive(pid: number | null | undefined) {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForPidExit(pid: number, timeoutMs = 2_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isPidAlive(pid)) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return !isPidAlive(pid);
}
async function spawnOrphanedProcessGroup() {
const leader = spawn(
process.execPath,
[
"-e",
[
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });",
"process.stdout.write(String(child.pid));",
"setTimeout(() => process.exit(0), 25);",
].join(" "),
],
{
detached: true,
stdio: ["ignore", "pipe", "ignore"],
},
);
let stdout = "";
leader.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
await new Promise<void>((resolve, reject) => {
leader.once("error", reject);
leader.once("exit", () => resolve());
});
const descendantPid = Number.parseInt(stdout.trim(), 10);
if (!Number.isInteger(descendantPid) || descendantPid <= 0) {
throw new Error(`Failed to capture orphaned descendant pid from detached process group: ${stdout}`);
}
return {
processPid: leader.pid ?? null,
processGroupId: leader.pid ?? null,
descendantPid,
};
}
describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const childProcesses = new Set<ChildProcess>();
const cleanupPids = new Set<number>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
@ -66,6 +126,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
child.kill("SIGKILL");
}
childProcesses.clear();
for (const pid of cleanupPids) {
try {
process.kill(pid, "SIGKILL");
} catch {
// Ignore already-dead cleanup targets.
}
}
cleanupPids.clear();
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
@ -79,6 +147,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
child.kill("SIGKILL");
}
childProcesses.clear();
for (const pid of cleanupPids) {
try {
process.kill(pid, "SIGKILL");
} catch {
// Ignore already-dead cleanup targets.
}
}
cleanupPids.clear();
runningProcesses.clear();
await tempDb?.cleanup();
});
@ -88,6 +164,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
agentStatus?: "paused" | "idle" | "running";
runStatus?: "running" | "queued" | "failed";
processPid?: number | null;
processGroupId?: number | null;
processLossRetryCount?: number;
includeIssue?: boolean;
runErrorCode?: string | null;
@ -143,6 +220,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
wakeupRequestId,
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
processPid: input?.processPid ?? null,
processGroupId: input?.processGroupId ?? null,
processLossRetryCount: input?.processLossRetryCount ?? 0,
errorCode: input?.runErrorCode ?? null,
error: input?.runError ?? null,
@ -228,6 +306,45 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(issue?.checkoutRunId).toBe(runId);
});
it.skipIf(process.platform === "win32")("reaps orphaned descendant process groups when the parent pid is already gone", async () => {
const orphan = await spawnOrphanedProcessGroup();
cleanupPids.add(orphan.descendantPid);
expect(isPidAlive(orphan.descendantPid)).toBe(true);
const { agentId, runId, issueId } = await seedRunFixture({
processPid: orphan.processPid,
processGroupId: orphan.processGroupId,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reapOrphanedRuns();
expect(result.reaped).toBe(1);
expect(result.runIds).toEqual([runId]);
expect(await waitForPidExit(orphan.descendantPid, 2_000)).toBe(true);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(2);
const failedRun = runs.find((row) => row.id === runId);
expect(failedRun?.status).toBe("failed");
expect(failedRun?.errorCode).toBe("process_lost");
expect(failedRun?.error).toContain("descendant process group");
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.status).toBe("queued");
const issue = await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.executionRunId).toBe(retryRun?.id ?? null);
});
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,

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
summarizeHeartbeatRunResultJson,
buildHeartbeatRunIssueComment,
mergeHeartbeatRunResultJson,
} from "../services/heartbeat-run-summary.js";
describe("summarizeHeartbeatRunResultJson", () => {
@ -55,3 +56,35 @@ describe("buildHeartbeatRunIssueComment", () => {
expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull();
});
});
describe("mergeHeartbeatRunResultJson", () => {
it("adds adapter summaries into stored result json for comment posting", () => {
const merged = mergeHeartbeatRunResultJson(
{ stdout: "raw stdout", stderr: "" },
"## Summary\n\n1. first thing\n2. second thing",
);
expect(merged).toEqual({
stdout: "raw stdout",
stderr: "",
summary: "## Summary\n\n1. first thing\n2. second thing",
});
expect(buildHeartbeatRunIssueComment(merged)).toBe("## Summary\n\n1. first thing\n2. second thing");
});
it("creates a result payload when only a summary exists", () => {
expect(mergeHeartbeatRunResultJson(null, "done")).toEqual({ summary: "done" });
});
it("does not overwrite an explicit summary already returned by the adapter", () => {
expect(
mergeHeartbeatRunResultJson(
{ summary: "adapter result", stdout: "raw stdout" },
"fallback summary",
),
).toEqual({
summary: "adapter result",
stdout: "raw stdout",
});
});
});

View file

@ -11,12 +11,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
function registerRouteMocks() {
vi.doMock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
}
vi.mock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
async function createApp(actor: any) {
const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([
@ -37,8 +35,7 @@ async function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,

View file

@ -1,8 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({
@ -170,7 +170,7 @@ describe("issue activity event routes", () => {
}),
}),
);
});
}, 15_000);
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
const existingPolicy = normalizeIssueExecutionPolicy({

View file

@ -38,48 +38,49 @@ const mockTx = vi.hoisted(() => ({
const mockDb = vi.hoisted(() => ({
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
}));
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
function registerServiceMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
function createApp() {
const app = express();
@ -134,7 +135,7 @@ function makeIssue(status: "todo" | "done") {
describe("issue comment reopen routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.resetAllMocks();
mockIssueService.getById.mockReset();
mockIssueService.assertCheckoutOwner.mockReset();
mockIssueService.update.mockReset();
@ -151,6 +152,11 @@ describe("issue comment reopen routes", () => {
mockHeartbeatService.cancelRun.mockReset();
mockAgentService.getById.mockReset();
mockLogActivity.mockReset();
mockFeedbackService.listIssueVotesForUser.mockReset();
mockFeedbackService.saveIssueVote.mockReset();
mockInstanceSettingsService.get.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset();
mockRoutineService.syncRunStatusForIssue.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDb.transaction.mockReset();
@ -163,6 +169,21 @@ describe("issue comment reopen routes", () => {
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockLogActivity.mockResolvedValue(undefined);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",

View file

@ -21,56 +21,60 @@ const mockIssueService = vi.hoisted(() => ({
const mockFeedbackExportService = vi.hoisted(() => ({
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: 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 mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
function registerServiceMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => ({
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),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
async function createApp(actor: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
@ -91,13 +95,27 @@ async function createApp(actor: Record<string, unknown>) {
describe("issue feedback trace routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.resetAllMocks();
mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({
attempted: 1,
sent: 1,
failed: 0,
});
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
});
it("flushes a newly shared feedback trace immediately after saving the vote", async () => {

View file

@ -1,6 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
@ -16,41 +18,39 @@ const mockAgentService = vi.hoisted(() => ({
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function makeIssue(status: "todo" | "done") {
return {
@ -65,11 +65,7 @@ function makeIssue(status: "todo" | "done") {
};
}
async function createApp(actor: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
]);
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -83,8 +79,6 @@ async function createApp(actor: Record<string, unknown>) {
describe("issue telemetry routes", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
@ -104,7 +98,7 @@ describe("issue telemetry routes", () => {
adapterType: "codex_local",
});
const app = await createApp({
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
@ -123,7 +117,7 @@ describe("issue telemetry routes", () => {
}, 10_000);
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
const app = await createApp({
const app = createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],

View file

@ -0,0 +1,56 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { llmRoutes } from "../routes/llms.js";
import { errorHandler } from "../middleware/index.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockListServerAdapters = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
}));
vi.mock("../adapters/index.js", () => ({
listServerAdapters: mockListServerAdapters,
}));
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", llmRoutes({} as never));
app.use(errorHandler);
return app;
}
describe("llm routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListServerAdapters.mockReturnValue([
{ type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" },
]);
});
it("documents timer heartbeats as opt-in for new hires", async () => {
const app = createApp({
type: "board",
userId: "board-user",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: true,
});
const res = await request(app).get("/api/llms/agent-configuration.txt");
expect(res.status).toBe(200);
expect(res.text).toContain("Timer heartbeats are opt-in for new hires.");
expect(res.text).toContain("Leave runtimeConfig.heartbeat.enabled false");
});
});

View file

@ -1,6 +1,8 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
hasPermission: vi.fn(),
@ -33,16 +35,14 @@ const mockBoardAuthService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
function registerServiceMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
}
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
function createDbStub() {
const createdInvite = {
@ -99,11 +99,7 @@ function createDbStub() {
};
}
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
import("../routes/access.js"),
import("../middleware/index.js"),
]);
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -125,9 +121,7 @@ async function createApp(actor: Record<string, unknown>, db: Record<string, unkn
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockAccessService.canUser.mockResolvedValue(false);
mockAgentService.getById.mockReset();
mockLogActivity.mockResolvedValue(undefined);
@ -140,7 +134,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
companyId: "company-1",
role: "engineer",
});
const app = await createApp(
const app = createApp(
{
type: "agent",
agentId: "agent-1",
@ -165,7 +159,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
companyId: "company-1",
role: "ceo",
});
const app = await createApp(
const app = createApp(
{
type: "agent",
agentId: "agent-1",
@ -193,7 +187,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("includes companyName in invite summary responses", async () => {
const db = createDbStub();
const app = await createApp(
const app = createApp(
{
type: "board",
userId: "user-1",
@ -215,7 +209,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("allows board callers with invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(true);
const app = await createApp(
const app = createApp(
{
type: "board",
userId: "user-1",
@ -238,12 +232,12 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
allowedJoinTypes: "agent",
}),
);
});
}, 15_000);
it("rejects board callers without invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp(
const app = createApp(
{
type: "board",
userId: "user-1",

View file

@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
const unknownHostname = "blocked-host.invalid";
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
const { privateHostnameGuard } = await import("../middleware/private-hostname-guard.js");
const app = express();
app.use(
privateHostnameGuard({
@ -24,33 +24,37 @@ function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHo
}
describe("privateHostnameGuard", () => {
beforeEach(() => {
vi.resetModules();
});
it("allows requests when disabled", async () => {
const app = createApp({ enabled: false });
const app = await createApp({ enabled: false });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(200);
});
it("allows loopback hostnames", async () => {
const app = createApp({ enabled: true });
const app = await createApp({ enabled: true });
const res = await request(app).get("/api/health").set("Host", "localhost:3100");
expect(res.status).toBe(200);
});
it("allows explicitly configured hostnames", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
const app = await createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(200);
});
it("blocks unknown hostnames with remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
});
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);

View file

@ -21,21 +21,23 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
function registerModuleMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/index.js", () => ({
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
vi.doMock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
}
async function createApp() {
const { projectRoutes } = await import("../routes/projects.js");
@ -97,6 +99,8 @@ function buildProject(overrides: Record<string, unknown> = {}) {
describe("project env routes", () => {
beforeEach(() => {
vi.resetModules();
registerModuleMocks();
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
@ -160,10 +164,6 @@ describe("project env routes", () => {
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockProjectService.update).toHaveBeenCalledWith(
"project-1",
expect.objectContaining({ env: normalizedEnv }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({

View file

@ -76,6 +76,21 @@ describe("TelemetryClient periodic flush", () => {
expect(fetch).not.toHaveBeenCalled();
});
it("falls back to the api gateway ingest url when the default hostname fails", async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(new TypeError("getaddrinfo ENOTFOUND telemetry.paperclip.ing"))
.mockResolvedValueOnce({ ok: true });
const client = makeClient({ endpoint: undefined });
client.track("install.started");
await client.flush();
expect(fetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe("https://telemetry.paperclip.ing/ingest");
expect(vi.mocked(fetch).mock.calls[1]?.[0]).toBe("https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest");
});
it("startPeriodicFlush is idempotent", () => {
const client = makeClient();
client.startPeriodicFlush(1000);