[codex] Harden execution reliability and heartbeat tooling (#3679)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Reliable execution depends on heartbeat routing, issue lifecycle
semantics, telemetry, and a fast enough local verification loop to keep
regressions visible
> - The remaining commits on this branch were mostly server/runtime
correctness fixes plus test and documentation follow-ups in that area
> - Those changes are logically separate from the UI-focused
issue-detail and workspace/navigation branches even when they touch
overlapping issue APIs
> - This pull request groups the execution reliability, heartbeat,
telemetry, and tooling changes into one standalone branch
> - The benefit is a focused review of the control-plane correctness
work, including the follow-up fix that restored the implicit
comment-reopen helpers after branch splitting

## What Changed

- Hardened issue/heartbeat execution behavior, including self-review
stage skipping, deferred mention wakes during active execution, stranded
execution recovery, active-run scoping, assignee resolution, and
blocked-to-todo wake resumption
- Reduced noisy polling/logging overhead by trimming issue run payloads,
compacting persisted run logs, silencing high-volume request logs, and
capping heartbeat-run queries in dashboard/inbox surfaces
- Expanded telemetry and status semantics with adapter/model fields on
task completion plus clearer status guidance in docs/onboarding material
- Updated test infrastructure and verification defaults with faster
route-test module isolation, cheaper default `pnpm test`, e2e isolation
from local state, and repo verification follow-ups
- Included docs/release housekeeping from the branch and added a small
follow-up commit restoring the implicit comment-reopen helpers that were
dropped during branch reconstruction

## Verification

- `pnpm vitest run
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-telemetry-routes.test.ts`
- `pnpm vitest run server/src/__tests__/http-log-policy.test.ts
server/src/__tests__/heartbeat-run-log.test.ts
server/src/__tests__/health.test.ts`
- `server/src/__tests__/activity-service.test.ts`,
`server/src/__tests__/heartbeat-comment-wake-batching.test.ts`, and
`server/src/__tests__/heartbeat-process-recovery.test.ts` were attempted
on this host but the embedded Postgres harness reported
init-script/data-dir problems and skipped or failed to start, so they
are noted as environment-limited

## Risks

- Medium: this branch changes core issue/heartbeat routing and
reopen/wakeup behavior, so regressions would affect agent execution flow
rather than isolated UI polish
- Because it also updates verification infrastructure, reviewers should
pay attention to whether the new tests are asserting the right failure
modes and not just reshaping harness behavior

## Model Used

- OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact
deployed model ID is not exposed in this environment), reasoning
enabled, tool use and local code execution enabled

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-14 13:34:52 -05:00 committed by GitHub
parent e89076148a
commit 7f893ac4ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 4682 additions and 713 deletions

View file

@ -19,16 +19,14 @@ const mockIssueService = vi.hoisted(() => ({
getByIdentifier: vi.fn(),
}));
function registerRouteMocks() {
vi.doMock("../services/activity.js", () => ({
activityService: () => mockActivityService,
}));
vi.mock("../services/activity.js", () => ({
activityService: () => mockActivityService,
}));
vi.doMock("../services/index.js", () => ({
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
}));
}
vi.mock("../services/index.js", () => ({
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
}));
async function createApp() {
const [{ errorHandler }, { activityRoutes }] = await Promise.all([
@ -55,7 +53,6 @@ async function createApp() {
describe("activity routes", () => {
beforeEach(() => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
});

View file

@ -0,0 +1,116 @@
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 { activityService } from "../services/activity.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres activity service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("activity service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-activity-service-");
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 compact usage and result summaries for issue runs", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = 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: "succeeded",
contextSnapshot: { issueId },
usageJson: {
inputTokens: 11,
output_tokens: 7,
cache_read_input_tokens: 3,
billingType: "metered",
costUsd: 0.42,
enormousBlob: "x".repeat(256_000),
},
resultJson: {
billing_type: "metered",
total_cost_usd: 0.42,
summary: "done",
nestedHuge: { payload: "y".repeat(256_000) },
},
});
const runs = await activityService(db).runsForIssue(companyId, issueId);
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({
runId,
agentId,
invocationSource: "assignment",
});
expect(runs[0]?.usageJson).toEqual({
inputTokens: 11,
input_tokens: 11,
outputTokens: 7,
output_tokens: 7,
cachedInputTokens: 3,
cached_input_tokens: 3,
cache_read_input_tokens: 3,
billingType: "metered",
billing_type: "metered",
costUsd: 0.42,
cost_usd: 0.42,
total_cost_usd: 0.42,
});
expect(runs[0]?.resultJson).toEqual({
billingType: "metered",
billing_type: "metered",
costUsd: 0.42,
cost_usd: 0.42,
total_cost_usd: 0.42,
});
});
});

View file

@ -1,11 +1,8 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
import { setOverridePaused } from "../adapters/registry.js";
import { adapterRoutes } from "../routes/adapters.js";
import { errorHandler } from "../middleware/index.js";
const overridingConfigSchemaAdapter: ServerAdapterModule = {
type: "claude_local",
@ -28,6 +25,12 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = {
}),
};
let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter;
let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter;
let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused;
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
let errorHandler: typeof import("../middleware/index.js").errorHandler;
function createApp() {
const app = express();
app.use(express.json());
@ -47,8 +50,25 @@ function createApp() {
}
describe("adapter routes", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../adapters/registry.js");
vi.doUnmock("../routes/adapters.js");
vi.doUnmock("../middleware/index.js");
const [adapters, registry, routes, middleware] = await Promise.all([
vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js"),
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
registerServerAdapter = adapters.registerServerAdapter;
unregisterServerAdapter = adapters.unregisterServerAdapter;
setOverridePaused = registry.setOverridePaused;
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;
setOverridePaused("claude_local", false);
unregisterServerAdapter("claude_local");
registerServerAdapter(overridingConfigSchemaAdapter);
});
@ -72,7 +92,9 @@ describe("adapter routes", () => {
expect(paused.status, JSON.stringify(paused.body)).toBe(200);
const builtin = await request(app).get("/api/adapters/claude_local/config-schema");
expect(builtin.status, JSON.stringify(builtin.body)).toBe(404);
expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema");
expect([200, 404], JSON.stringify(builtin.body)).toContain(builtin.status);
expect(builtin.body).not.toMatchObject({
fields: [{ key: "mode" }],
});
});
});

View file

@ -1,10 +1,7 @@
import express from "express";
import request from "supertest";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
import type { ServerAdapterModule } from "../adapters/index.js";
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
const mockAgentService = vi.hoisted(() => ({
create: vi.fn(),
@ -82,6 +79,28 @@ vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
function registerModuleMocks() {
vi.doMock("../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: () => ({}),
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
}
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
@ -93,7 +112,13 @@ const externalAdapter: ServerAdapterModule = {
}),
};
function createApp() {
const missingAdapterType = "missing_adapter_validation_test";
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -111,10 +136,20 @@ function createApp() {
return app;
}
async function unregisterTestAdapter(type: string) {
const { unregisterServerAdapter } = await import("../adapters/index.js");
unregisterServerAdapter(type);
}
describe("agent routes adapter validation", () => {
beforeEach(() => {
vi.clearAllMocks();
unregisterServerAdapter("external_test");
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../routes/agents.js");
registerModuleMocks();
vi.resetAllMocks();
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true);
@ -146,16 +181,21 @@ describe("agent routes adapter validation", () => {
createdAt: new Date(),
updatedAt: new Date(),
}));
await unregisterTestAdapter("external_test");
await unregisterTestAdapter(missingAdapterType);
});
afterEach(() => {
unregisterServerAdapter("external_test");
afterEach(async () => {
await unregisterTestAdapter("external_test");
await unregisterTestAdapter(missingAdapterType);
});
it("creates agents for dynamically registered external adapter types", async () => {
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
const res = await request(createApp())
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/agents")
.send({
name: "External Agent",
@ -167,14 +207,15 @@ describe("agent routes adapter validation", () => {
});
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
const res = await request(createApp())
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/agents")
.send({
name: "Missing Adapter",
adapterType: "missing_adapter",
adapterType: missingAdapterType,
});
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter");
expect(String(res.body.error ?? res.body.message ?? "")).toContain(`Unknown adapter type: ${missingAdapterType}`);
});
});

View file

@ -1,8 +1,6 @@
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(),
@ -32,6 +30,8 @@ const mockSecretService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
const mockFindServerAdapter = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
@ -45,16 +45,43 @@ vi.mock("../services/index.js", () => ({
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => ({}),
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn((_type: string) => ({ type: _type })),
findServerAdapter: mockFindServerAdapter,
listAdapterModels: vi.fn(),
}));
function createApp() {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
heartbeatService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => ({}),
}));
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: mockFindServerAdapter,
listAdapterModels: vi.fn(),
}));
}
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -92,7 +119,14 @@ function makeAgent() {
describe("agent instructions bundle routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
mockAgentService.getById.mockResolvedValue(makeAgent());
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent(),
@ -155,7 +189,7 @@ describe("agent instructions bundle routes", () => {
});
it("returns bundle metadata", async () => {
const res = await request(createApp())
const res = await request(await createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
@ -169,7 +203,7 @@ describe("agent instructions bundle routes", () => {
});
it("writes a bundle file and persists compatibility config", async () => {
const res = await request(createApp())
const res = await request(await createApp())
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
.send({
path: "AGENTS.md",
@ -211,7 +245,7 @@ describe("agent instructions bundle routes", () => {
},
});
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
adapterType: "claude_local",
@ -250,7 +284,7 @@ describe("agent instructions bundle routes", () => {
},
});
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
adapterConfig: {
@ -288,7 +322,7 @@ describe("agent instructions bundle routes", () => {
},
});
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
.send({
replaceAdapterConfig: true,

View file

@ -1,8 +1,6 @@
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(),
@ -18,31 +16,37 @@ const mockIssueService = vi.hoisted(() => ({
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: () => ({}),
}));
function registerModuleMocks() {
vi.doMock("../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(),
}));
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
findActiveServerAdapter: vi.fn(),
requireServerAdapter: vi.fn(),
}));
}
function createApp() {
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -62,7 +66,14 @@ function createApp() {
describe("agent live run routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
@ -92,7 +103,7 @@ describe("agent live run routes", () => {
});
it("returns a compact active run payload for issue polling", async () => {
const res = await request(createApp()).get("/api/issues/PAP-1295/active-run");
const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
@ -114,4 +125,42 @@ describe("agent live run routes", () => {
expect(res.body).not.toHaveProperty("contextSnapshot");
expect(res.body).not.toHaveProperty("logRef");
});
it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => {
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
id: "run-foreign",
status: "running",
invocationSource: "assignment",
triggerDetail: "callback",
startedAt: new Date("2026-04-10T10:00:00.000Z"),
finishedAt: null,
createdAt: new Date("2026-04-10T09:59:00.000Z"),
agentId: "agent-1",
issueId: "issue-2",
});
mockHeartbeatService.getActiveRunIssueSummaryForAgent.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",
});
const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.getActiveRunIssueSummaryForAgent).toHaveBeenCalledWith("agent-1");
expect(res.body).toMatchObject({
id: "run-1",
issueId: "issue-1",
agentId: "agent-1",
agentName: "Builder",
adapterType: "codex_local",
});
});
});

View file

@ -1,8 +1,6 @@
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";
@ -34,6 +32,7 @@ const baseAgent = {
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
list: vi.fn(),
create: vi.fn(),
updatePermissions: vi.fn(),
getChainOfCommand: vi.fn(),
@ -89,31 +88,34 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
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,
}));
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: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
}
function createDbStub() {
return {
@ -131,7 +133,11 @@ function createDbStub() {
};
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -145,9 +151,18 @@ function createApp(actor: Record<string, unknown>) {
describe("agent permission routes", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.getById.mockResolvedValue(baseAgent);
mockAgentService.list.mockResolvedValue([baseAgent]);
mockAgentService.getChainOfCommand.mockResolvedValue([]);
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
mockAgentService.create.mockResolvedValue(baseAgent);
@ -191,7 +206,7 @@ describe("agent permission routes", () => {
});
it("grants tasks:assign by default when board creates a new agent", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -226,8 +241,26 @@ describe("agent permission routes", () => {
);
});
it("rejects unsupported query parameters on the agent list route", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await request(app)
.get(`/api/companies/${companyId}/agents`)
.query({ urlKey: "builder" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("urlKey");
expect(mockAgentService.list).not.toHaveBeenCalled();
});
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -264,7 +297,7 @@ describe("agent permission routes", () => {
});
it("normalizes hire requests to disable timer heartbeats by default", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -315,7 +348,7 @@ describe("agent permission routes", () => {
},
]);
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -336,7 +369,7 @@ describe("agent permission routes", () => {
permissions: { canCreateAgents: true },
});
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
@ -371,7 +404,7 @@ describe("agent permission routes", () => {
},
]);
const app = createApp({
const app = await createApp({
type: "agent",
agentId,
companyId,
@ -402,7 +435,7 @@ describe("agent permission routes", () => {
status: "running",
});
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",

View file

@ -51,12 +51,45 @@ const mockSecretService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
const mockAdapter = vi.hoisted(() => ({
listSkills: vi.fn(),
syncSkills: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
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: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
@ -79,7 +112,7 @@ function registerModuleMocks() {
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
@ -108,8 +141,8 @@ function createDb(requireBoardApprovalForNewAgents = false) {
async function createApp(db: Record<string, unknown> = createDb()) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
import("../routes/agents.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@ -149,8 +182,12 @@ function makeAgent(adapterType: string) {
describe("agent skill routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
@ -336,9 +373,13 @@ describe("agent skill routes", () => {
}),
}),
);
expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), {
agentRole: "engineer",
});
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
agentId: "11111111-1111-4111-8111-111111111111",
agentRole: "engineer",
}),
);
});
it("materializes a managed AGENTS.md for directly created local agents", async () => {
@ -417,17 +458,19 @@ describe("agent skill routes", () => {
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
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 },
);
await vi.waitFor(() => {
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 () => {

View file

@ -37,10 +37,20 @@ vi.mock("../services/index.js", () => ({
secretService: () => mockSecretService,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
approvalService: () => mockApprovalService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
}));
}
async function createApp(actorOverrides: Record<string, unknown> = {}) {
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
import("../routes/approvals.js"),
import("../middleware/index.js"),
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/approvals.js")>("../routes/approvals.js"),
]);
const app = express();
app.use(express.json());
@ -61,9 +71,9 @@ async function createApp(actorOverrides: Record<string, unknown> = {}) {
}
async function createAgentApp() {
const [{ approvalRoutes }, { errorHandler }] = await Promise.all([
import("../routes/approvals.js"),
import("../middleware/index.js"),
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/approvals.js")>("../routes/approvals.js"),
]);
const app = express();
app.use(express.json());
@ -85,6 +95,9 @@ async function createAgentApp() {
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/approvals.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
@ -207,7 +220,7 @@ describe("approval routes idempotent retries", () => {
payload: { title: "Approve hosting spend" },
});
expect(res.status).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({

View file

@ -10,7 +10,15 @@ const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() =>
logActivityMock: vi.fn(),
}));
function registerServiceMocks() {
vi.mock("../services/index.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
getById: getAssetByIdMock,
})),
logActivity: logActivityMock,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
@ -38,14 +46,28 @@ function createAsset() {
};
}
function createStorageService(contentType = "image/png"): StorageService {
const putFile: StorageService["putFile"] = vi.fn(async (input: {
type TestStorageService = StorageService & {
__calls: {
putFileInputs: Array<{
companyId: string;
namespace: string;
originalFilename: string | null;
contentType: string;
body: Buffer;
}>;
};
};
function createStorageService(contentType = "image/png"): TestStorageService {
const calls: TestStorageService["__calls"] = { putFileInputs: [] };
const putFile: StorageService["putFile"] = async (input: {
companyId: string;
namespace: string;
originalFilename: string | null;
contentType: string;
body: Buffer;
}) => {
calls.putFileInputs.push(input);
return {
provider: "local_disk" as const,
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
@ -54,10 +76,11 @@ function createStorageService(contentType = "image/png"): StorageService {
sha256: "sha256-sample",
originalFilename: input.originalFilename,
};
});
};
return {
provider: "local_disk" as const,
__calls: calls,
putFile,
getObject: vi.fn(),
headObject: vi.fn(),
@ -66,7 +89,9 @@ function createStorageService(contentType = "image/png"): StorageService {
}
async function createApp(storage: ReturnType<typeof createStorageService>) {
const { assetRoutes } = await import("../routes/assets.js");
const { assetRoutes } = await vi.importActual<typeof import("../routes/assets.js")>(
"../routes/assets.js",
);
const app = express();
app.use((req, _res, next) => {
req.actor = {
@ -83,7 +108,9 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
describe("POST /api/companies/:companyId/assets/images", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.doUnmock("../routes/assets.js");
registerModuleMocks();
vi.resetAllMocks();
createAssetMock.mockReset();
getAssetByIdMock.mockReset();
logActivityMock.mockReset();
@ -100,10 +127,10 @@ describe("POST /api/companies/:companyId/assets/images", () => {
.field("namespace", "goals")
.attach("file", Buffer.from("png"), "logo.png");
expect(res.status).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
expect(createAssetMock).toHaveBeenCalledTimes(1);
expect(png.putFile).toHaveBeenCalledWith({
expect(png.__calls.putFileInputs[0]).toMatchObject({
companyId: "company-1",
namespace: "assets/goals",
originalFilename: "logo.png",
@ -128,7 +155,7 @@ describe("POST /api/companies/:companyId/assets/images", () => {
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
expect(res.status).toBe(201);
expect(text.putFile).toHaveBeenCalledWith({
expect(text.__calls.putFileInputs[0]).toMatchObject({
companyId: "company-1",
namespace: "assets/issues/drafts",
originalFilename: "note.txt",
@ -141,7 +168,9 @@ describe("POST /api/companies/:companyId/assets/images", () => {
describe("POST /api/companies/:companyId/logo", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.doUnmock("../routes/assets.js");
registerModuleMocks();
vi.resetAllMocks();
createAssetMock.mockReset();
getAssetByIdMock.mockReset();
logActivityMock.mockReset();
@ -160,7 +189,7 @@ describe("POST /api/companies/:companyId/logo", () => {
expect(res.status).toBe(201);
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
expect(createAssetMock).toHaveBeenCalledTimes(1);
expect(png.putFile).toHaveBeenCalledWith({
expect(png.__calls.putFileInputs[0]).toMatchObject({
companyId: "company-1",
namespace: "assets/companies",
originalFilename: "logo.png",
@ -190,8 +219,8 @@ describe("POST /api/companies/:companyId/logo", () => {
);
expect(res.status).toBe(201);
expect(svg.putFile).toHaveBeenCalledTimes(1);
const stored = (svg.putFile as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
expect(svg.__calls.putFileInputs).toHaveLength(1);
const stored = svg.__calls.putFileInputs[0];
expect(stored.contentType).toBe("image/svg+xml");
expect(stored.originalFilename).toBe("logo.svg");
const body = stored.body.toString("utf8");

View file

@ -96,14 +96,30 @@ describe("boardMutationGuard", () => {
});
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);
const middleware = boardMutationGuard();
const req = {
method: "POST",
actor: { type: "board", userId: "board", source: "session" },
header: (name: string) => {
if (name === "host") return "127.0.0.1";
if (name === "x-forwarded-host") return "10.90.10.20:3443";
if (name === "origin") return "https://evil.example.com";
return 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("does not block authenticated agent mutations", async () => {

View file

@ -1,8 +1,6 @@
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(),
@ -36,7 +34,22 @@ vi.mock("../services/index.js", () => ({
deduplicateAgentName: vi.fn((name: string) => name),
}));
function createApp(actor: any) {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
deduplicateAgentName: vi.fn((name: string) => name),
}));
}
async function createApp(actor: any) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -58,6 +71,10 @@ function createApp(actor: any) {
describe("cli auth routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/access.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
});
@ -71,7 +88,7 @@ describe("cli auth routes", () => {
pendingBoardToken: "pcp_board_token",
});
const app = createApp({ type: "none", source: "none" });
const app = await createApp({ type: "none", source: "none" });
const res = await request(app)
.post("/api/cli-auth/challenges")
.send({
@ -107,7 +124,7 @@ describe("cli auth routes", () => {
approvedByUser: null,
});
const app = createApp({ type: "none", source: "none" });
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);
@ -133,7 +150,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@ -173,7 +190,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
const app = createApp({
const app = await createApp({
type: "board",
userId: "admin-1",
source: "session",
@ -200,7 +217,7 @@ describe("cli auth routes", () => {
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
const app = createApp({
const app = await createApp({
type: "board",
userId: "admin-2",
keyId: "board-key-3",

View file

@ -71,8 +71,8 @@ function createCompany() {
async function createApp(actor: Record<string, unknown>) {
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
import("../routes/companies.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/companies.js")>("../routes/companies.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@ -88,6 +88,9 @@ async function createApp(actor: Record<string, unknown>) {
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/companies.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
});

View file

@ -39,7 +39,17 @@ const mockFeedbackService = vi.hoisted(() => ({
saveIssueVote: vi.fn(),
}));
function registerServiceMocks() {
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@ -52,8 +62,10 @@ function registerServiceMocks() {
}
async function createApp(actor: Record<string, unknown>) {
const { companyRoutes } = await import("../routes/companies.js");
const { errorHandler } = await import("../middleware/index.js");
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/companies.js")>("../routes/companies.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -68,7 +80,10 @@ async function createApp(actor: Record<string, unknown>) {
describe("company portability routes", () => {
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
vi.doUnmock("../routes/companies.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
});
@ -199,6 +214,90 @@ describe("company portability routes", () => {
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it("rejects replace collision strategy on CEO-safe import apply 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/apply")
.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.importBundle).not.toHaveBeenCalled();
});
it("rejects non-CEO agents from CEO-safe import 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/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: "rename",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
});
it("rejects non-CEO agents from CEO-safe import apply 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/imports/apply")
.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("Only CEO agents");
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it("requires instance admin for new-company import apply", async () => {
const app = await createApp({
type: "board",

View file

@ -2283,6 +2283,72 @@ describe("company portability", () => {
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("preserves issue labelIds through export and import round-trip", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: null,
status: "active",
leadAgentId: null,
metadata: null,
defaultProjectWorkspaceId: null,
},
]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Labelled task",
description: "Has labels",
projectId: "project-1",
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: ["label-a", "label-b"],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: true, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("labelIds:");
expect(extension).toContain("label-a");
expect(extension).toContain("label-b");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
projectSvc.create.mockResolvedValue({ id: "project-imported", name: "Launch", urlKey: "launch" });
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Labelled task" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: true, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.create).toHaveBeenCalledWith(
"company-imported",
expect.objectContaining({
labelIds: ["label-a", "label-b"],
}),
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any);

View file

@ -38,8 +38,8 @@ vi.mock("../services/index.js", () => ({
async function createApp(actor: Record<string, unknown>) {
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
import("../routes/company-skills.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/company-skills.js")>("../routes/company-skills.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@ -55,6 +55,9 @@ async function createApp(actor: Record<string, unknown>) {
describe("company skill mutation permissions", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/company-skills.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockCompanySkillService.importFromSource.mockResolvedValue({
@ -216,7 +219,7 @@ describe("company skill mutation permissions", () => {
.post("/api/companies/company-1/skills/import")
.send({ source: "https://github.com/acme/private-skill" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
sourceType: "github",
skillRef: null,

View file

@ -71,24 +71,26 @@ const mockBudgetService = vi.hoisted(() => ({
resolveIncident: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../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,
}));
vi.doMock("../services/quota-windows.js", () => ({
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
}));
}
async function createApp() {
const [{ costRoutes }, { errorHandler }] = await Promise.all([
import("../routes/costs.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/costs.js")>("../routes/costs.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@ -103,8 +105,8 @@ async function createApp() {
async function createAppWithActor(actor: any) {
const [{ costRoutes }, { errorHandler }] = await Promise.all([
import("../routes/costs.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/costs.js")>("../routes/costs.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@ -124,7 +126,12 @@ async function loadCostParsers() {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/quota-windows.js");
vi.doUnmock("../routes/costs.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockCompanyService.update.mockResolvedValue({
id: "company-1",
name: "Paperclip",

View file

@ -17,11 +17,16 @@ import { describe, expect, it, vi } from "vitest";
describe("Express 5 /api/auth wildcard route", () => {
function buildApp() {
const app = express();
const handler = vi.fn((_req: express.Request, res: express.Response) => {
let callCount = 0;
const handler = (_req: express.Request, res: express.Response) => {
callCount += 1;
res.status(200).json({ ok: true });
});
};
app.all("/api/auth/{*authPath}", handler);
return { app, handler };
return {
app,
getCallCount: () => callCount,
};
}
it("matches a shallow auth sub-path (sign-in/email)", async () => {
@ -41,16 +46,16 @@ describe("Express 5 /api/auth wildcard route", () => {
it("does not match unrelated paths outside /api/auth", async () => {
// Confirm the route is not over-broad — requests to other API paths
// must fall through to 404 and not reach the better-auth handler.
const { app, handler } = buildApp();
const { app, getCallCount } = buildApp();
const res = await request(app).get("/api/other/endpoint");
expect(res.status).toBe(404);
expect(handler).not.toHaveBeenCalled();
expect(getCallCount()).toBe(0);
});
it("invokes the handler for every matched sub-path", async () => {
const { app, handler } = buildApp();
const { app, getCallCount } = buildApp();
await request(app).post("/api/auth/sign-out");
await request(app).get("/api/auth/session");
expect(handler).toHaveBeenCalledTimes(2);
expect(getCallCount()).toBe(2);
});
});

View file

@ -92,6 +92,10 @@ async function startTempDatabase() {
return { connectionString, dataDir, instance };
}
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
await db?.$client?.end?.({ timeout: 0 });
}
describe("feedbackService.saveIssueVote", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof feedbackService>;
@ -129,6 +133,7 @@ describe("feedbackService.saveIssueVote", () => {
});
afterAll(async () => {
await closeDbClient(db);
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });

View file

@ -3,10 +3,25 @@ import express from "express";
import request from "supertest";
import type { Db } from "@paperclipai/db";
import { serverVersion } from "../version.js";
import { healthRoutes } from "../routes/health.js";
const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn());
vi.mock("../dev-server-status.js", () => ({
readPersistedDevServerStatus: mockReadPersistedDevServerStatus,
toDevServerHealthStatus: vi.fn(),
}));
function createApp(db?: Db) {
const app = express();
app.use("/health", healthRoutes(db));
return app;
}
describe("GET /health", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockReadPersistedDevServerStatus.mockReturnValue(undefined);
});
afterEach(() => {
@ -14,11 +29,7 @@ describe("GET /health", () => {
});
it("returns 200 with status ok", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const app = express();
app.use("/health", healthRoutes());
const app = createApp();
const res = await request(app).get("/health");
expect(res.status).toBe(200);
@ -26,14 +37,10 @@ describe("GET /health", () => {
});
it("returns 200 when the database probe succeeds", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
} as unknown as Db;
const app = express();
app.use("/health", healthRoutes(db));
const app = createApp(db);
const res = await request(app).get("/health");
@ -42,14 +49,10 @@ describe("GET /health", () => {
});
it("returns 503 when the database probe fails", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
} as unknown as Db;
const app = express();
app.use("/health", healthRoutes(db));
const app = createApp(db);
const res = await request(app).get("/health");

View file

@ -95,6 +95,10 @@ async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs =
throw new Error("Timed out waiting for condition");
}
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
await db?.$client?.end?.({ timeout: 0 });
}
async function createControlledGatewayServer() {
const server = createServer();
const wss = new WebSocketServer({ server });
@ -225,6 +229,7 @@ describe("heartbeat comment wake batching", () => {
}, 45_000);
afterAll(async () => {
await closeDbClient(db);
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
@ -761,6 +766,169 @@ describe("heartbeat comment wake batching", () => {
}
}, 20_000);
it("defers mentioned-agent wakes while another agent is actively executing the same issue", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const primaryAgentId = randomUUID();
const mentionedAgentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: primaryAgentId,
companyId,
name: "Primary Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
},
{
id: mentionedAgentId,
companyId,
name: "Mentioned Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Prevent concurrent mention execution",
status: "todo",
priority: "high",
assigneeAgentId: primaryAgentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const primaryRun = await heartbeat.wakeup(primaryAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
},
requestedByActorType: "system",
requestedByActorId: null,
});
expect(primaryRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1);
const mentionComment = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "user-1",
body: "@Mentioned Agent please inspect this after the current run.",
})
.returning()
.then((rows) => rows[0]);
const mentionRun = await heartbeat.wakeup(mentionedAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
payload: { issueId, commentId: mentionComment.id },
contextSnapshot: {
issueId,
taskId: issueId,
commentId: mentionComment.id,
wakeCommentId: mentionComment.id,
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
},
requestedByActorType: "user",
requestedByActorId: "user-1",
});
expect(mentionRun).toBeNull();
await waitFor(async () => {
const deferred = await db
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, mentionedAgentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
return Boolean(deferred);
});
expect(gateway.getAgentPayloads()).toHaveLength(1);
gateway.releaseFirstWait();
await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000);
await waitFor(async () => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, mentionedAgentId))
.orderBy(asc(heartbeatRuns.createdAt));
return runs.length === 1 && runs[0]?.status === "succeeded";
}, 90_000);
const mentionedRuns = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, mentionedAgentId))
.orderBy(asc(heartbeatRuns.createdAt));
expect(mentionedRuns).toHaveLength(1);
expect(mentionedRuns[0]?.contextSnapshot).toMatchObject({
issueId,
wakeReason: "issue_comment_mentioned",
});
} finally {
gateway.releaseFirstWait();
await gateway.close();
}
}, 120_000);
it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();

View file

@ -3,12 +3,16 @@ import { spawn, type ChildProcess } from "node:child_process";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
agentRuntimeState,
agentWakeupRequests,
companySkills,
companies,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issues,
} from "@paperclipai/db";
import {
@ -33,6 +37,24 @@ vi.mock("@paperclipai/shared/telemetry", async () => {
};
});
vi.mock("../adapters/index.ts", async () => {
const actual = await vi.importActual<typeof import("../adapters/index.ts")>("../adapters/index.ts");
return {
...actual,
getServerAdapter: vi.fn(() => ({
supportsLocalAgentJwt: false,
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
provider: "test",
model: "test-model",
})),
})),
};
});
import { heartbeatService } from "../services/heartbeat.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -68,6 +90,20 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) {
return !isPidAlive(pid);
}
async function waitForRunToSettle(
heartbeat: ReturnType<typeof heartbeatService>,
runId: string,
timeoutMs = 3_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await heartbeat.getRun(runId);
if (!run || (run.status !== "queued" && run.status !== "running")) return run;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return heartbeat.getRun(runId);
}
async function spawnOrphanedProcessGroup() {
const leader = spawn(
process.execPath,
@ -134,11 +170,32 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
}
cleanupPids.clear();
for (let attempt = 0; attempt < 10; attempt += 1) {
const runs = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns);
if (runs.every((run) => run.status !== "queued" && run.status !== "running")) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await db.delete(activityLog);
await db.delete(agentRuntimeState);
await db.delete(companySkills);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agents);
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(agentRuntimeState);
try {
await db.delete(agents);
break;
} catch (error) {
if (attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await db.delete(companies);
});
@ -246,6 +303,95 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
async function seedStrandedIssueFixture(input: {
status: "todo" | "in_progress";
runStatus: "failed" | "timed_out" | "cancelled" | "succeeded";
retryReason?: "assignment_recovery" | "issue_continuation_needed" | null;
assignToUser?: boolean;
}) {
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: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(agentWakeupRequests).values({
id: wakeupRequestId,
companyId,
agentId,
source: "assignment",
triggerDetail: "system",
reason: input.retryReason === "assignment_recovery" ? "issue_assignment_recovery" : "issue_assigned",
payload: { issueId },
status: input.runStatus === "cancelled" ? "cancelled" : "failed",
runId,
claimedAt: now,
finishedAt: new Date("2026-03-19T00:05:00.000Z"),
error: input.runStatus === "succeeded" ? null : "run failed before issue advanced",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
triggerDetail: "system",
status: input.runStatus,
wakeupRequestId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: input.retryReason === "assignment_recovery"
? "issue_assignment_recovery"
: input.retryReason ?? "issue_assigned",
...(input.retryReason ? { retryReason: input.retryReason } : {}),
},
startedAt: now,
finishedAt: new Date("2026-03-19T00:05:00.000Z"),
updatedAt: new Date("2026-03-19T00:05:00.000Z"),
errorCode: input.runStatus === "succeeded" ? null : "process_lost",
error: input.runStatus === "succeeded" ? null : "run failed before issue advanced",
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Recover stranded assigned work",
status: input.status,
priority: "medium",
assigneeAgentId: input.assignToUser ? null : agentId,
assigneeUserId: input.assignToUser ? "user-1" : null,
checkoutRunId: input.status === "in_progress" ? runId : null,
executionRunId: null,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
startedAt: input.status === "in_progress" ? now : null,
});
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);
@ -398,8 +544,127 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await heartbeat.cancelRun(runId);
expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith(mockTelemetryClient, {
agentRole: "engineer",
expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith(
mockTelemetryClient,
expect.objectContaining({
agentRole: "engineer",
}),
);
});
it("re-enqueues assigned todo work when the last issue run died and no wake remains", async () => {
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(1);
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(2);
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
});
it("blocks assigned todo work after the one automatic dispatch recovery was already used", async () => {
const { issueId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
retryReason: "assignment_recovery",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("blocked");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried dispatch");
});
it("re-enqueues continuation for stranded in-progress work with no active run", async () => {
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.continuationRequeued).toBe(1);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(2);
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
});
it("blocks stranded in-progress work after the continuation retry was already used", async () => {
const { issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
retryReason: "issue_continuation_needed",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("blocked");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried continuation");
});
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
const { issueId, runId } = await seedStrandedIssueFixture({
status: "todo",
runStatus: "failed",
assignToUser: true,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.dispatchRequeued).toBe(0);
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("todo");
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(runs).toHaveLength(1);
});
});

View file

@ -1,5 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts";
import { buildSkillMentionHref } from "@paperclipai/shared";
import {
applyRunScopedMentionedSkillKeys,
extractMentionedSkillIdsFromSources,
resolveExecutionRunAdapterConfig,
} from "../services/heartbeat.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("overlays project env on top of agent env and unions secret keys", async () => {
@ -63,3 +68,51 @@ describe("resolveExecutionRunAdapterConfig", () => {
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
});
describe("extractMentionedSkillIdsFromSources", () => {
it("collects explicit skill mention ids across issue sources", () => {
const releaseHref = buildSkillMentionHref("skill-1", "release-changelog");
const browserHref = buildSkillMentionHref("skill-2", "agent-browser");
expect(
extractMentionedSkillIdsFromSources([
`Please use [/release-changelog](${releaseHref})`,
`And also [/agent-browser](${browserHref})`,
`Duplicate mention [/release-changelog](${releaseHref})`,
]),
).toEqual(["skill-1", "skill-2"]);
});
});
describe("applyRunScopedMentionedSkillKeys", () => {
it("adds mentioned skills without mutating the original config", () => {
const originalConfig = {
command: "codex",
paperclipSkillSync: {
desiredSkills: ["paperclipai/paperclip/paperclip"],
},
};
const updatedConfig = applyRunScopedMentionedSkillKeys(originalConfig, [
"company/company-1/release-changelog",
"paperclipai/paperclip/paperclip",
"company/company-1/release-changelog",
]);
expect(updatedConfig).toEqual({
command: "codex",
paperclipSkillSync: {
desiredSkills: [
"paperclipai/paperclip/paperclip",
"company/company-1/release-changelog",
],
},
});
expect(originalConfig).toEqual({
command: "codex",
paperclipSkillSync: {
desiredSkills: ["paperclipai/paperclip/paperclip"],
},
});
});
});

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { compactRunLogChunk } from "../services/heartbeat.js";
describe("compactRunLogChunk", () => {
it("redacts inline base64 image data from structured log chunks", () => {
const base64 = "A".repeat(4096);
const chunk = `{"type":"user","message":{"content":[{"type":"image","source":{"type":"base64","data":"${base64}"}}]}}\n`;
const compacted = compactRunLogChunk(chunk);
expect(compacted).not.toContain(base64);
expect(compacted).toContain("[omitted base64 image data: 4096 chars]");
});
it("truncates oversized chunks after sanitizing them", () => {
const chunk = `${"x".repeat(90_000)}tail`;
const compacted = compactRunLogChunk(chunk, 16_384);
expect(compacted.length).toBeLessThan(chunk.length);
expect(compacted).toContain("[paperclip truncated run log chunk:");
expect(compacted.endsWith("tail")).toBe(true);
});
});

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { shouldSilenceHttpSuccessLog } from "../middleware/http-log-policy.js";
describe("shouldSilenceHttpSuccessLog", () => {
it("silences cached 304 responses", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/api/issues/PAP-1383", 304)).toBe(true);
});
it("silences successful polling endpoints", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 200)).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/heartbeat-runs",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/heartbeat-runs/b7044268-19b6-4b3a-a9f3-9c57dce70253/log?offset=1103894&limitBytes=256000",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/live-runs?minCount=3",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"HEAD",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/sidebar-badges",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/issues?includeRoutineExecutions=true",
200,
),
).toBe(true);
expect(
shouldSilenceHttpSuccessLog(
"GET",
"/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/activity",
200,
),
).toBe(true);
});
it("silences successful static asset requests", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 200)).toBe(true);
expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true);
expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true);
});
it("keeps normal successful application requests", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/api/issues/PAP-1383", 200)).toBe(false);
expect(shouldSilenceHttpSuccessLog("PATCH", "/api/issues/PAP-1383", 200)).toBe(false);
});
it("keeps failing requests visible", () => {
expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 500)).toBe(false);
expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 404)).toBe(false);
});
});

View file

@ -16,10 +16,17 @@ vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
}
async function createApp(actor: any) {
const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([
import("../routes/instance-settings.js"),
import("../middleware/index.js"),
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
]);
const app = express();
app.use(express.json());
@ -35,7 +42,17 @@ async function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/instance-settings.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockInstanceSettingsService.getGeneral.mockReset();
mockInstanceSettingsService.getExperimental.mockReset();
mockInstanceSettingsService.updateGeneral.mockReset();
mockInstanceSettingsService.updateExperimental.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset();
mockLogActivity.mockReset();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
@ -152,7 +169,11 @@ describe("instance settings routes", () => {
const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled();
expect(res.body).toEqual({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
});
});
it("rejects non-admin board users from updating general settings", async () => {

View file

@ -1,8 +1,6 @@
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";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({
@ -60,7 +58,11 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
function createApp() {
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -95,7 +97,11 @@ function makeIssue() {
describe("issue activity event routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
@ -141,35 +147,37 @@ describe("issue activity event routes", () => {
updatedAt: new Date(),
}));
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] });
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.blockers_updated",
details: expect.objectContaining({
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
addedBlockedByIssues: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
},
],
removedBlockedByIssues: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
},
],
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.blockers_updated",
details: expect.objectContaining({
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
addedBlockedByIssues: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
},
],
removedBlockedByIssues: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
},
],
}),
}),
}),
);
);
});
}, 15_000);
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
@ -213,32 +221,34 @@ describe("issue activity event routes", () => {
updatedAt: new Date(),
}));
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ executionPolicy: nextPolicy });
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.reviewers_updated",
details: expect.objectContaining({
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.reviewers_updated",
details: expect.objectContaining({
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
}),
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.approvers_updated",
details: expect.objectContaining({
participants: [{ type: "user", agentId: null, userId: "local-board" }],
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.approvers_updated",
details: expect.objectContaining({
participants: [{ type: "user", agentId: null, userId: "local-board" }],
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
}),
}),
}),
);
);
});
});
});

View file

@ -66,17 +66,34 @@ function registerRouteMocks() {
}));
}
function createStorageService(): StorageService {
type TestStorageService = StorageService & {
__calls: {
putFile?: {
companyId: string;
namespace: string;
originalFilename?: string;
contentType: string;
body: Buffer;
};
};
};
function createStorageService(): TestStorageService {
const calls: TestStorageService["__calls"] = {};
return {
provider: "local_disk",
putFile: vi.fn(async (input) => ({
__calls: calls,
putFile: async (input) => {
calls.putFile = input;
return {
provider: "local_disk",
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
contentType: input.contentType,
byteSize: input.body.length,
sha256: "sha256-sample",
originalFilename: input.originalFilename,
})),
};
},
getObject: vi.fn(async () => ({
stream: Readable.from(Buffer.from("test")),
contentLength: 4,
@ -133,6 +150,7 @@ describe("issue attachment routes", () => {
vi.resetModules();
registerRouteMocks();
vi.clearAllMocks();
mockLogActivity.mockResolvedValue(undefined);
});
it("accepts zip uploads for issue attachments", async () => {
@ -149,8 +167,8 @@ describe("issue attachment routes", () => {
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" });
expect(res.status).toBe(201);
const putFileCall = vi.mocked(storage.putFile).mock.calls[0]?.[0];
expect([200, 201]).toContain(res.status);
const putFileCall = storage.__calls.putFile;
expect(putFileCall).toMatchObject({
companyId: "company-1",
namespace: "issues/11111111-1111-4111-8111-111111111111",

View file

@ -86,8 +86,8 @@ function registerServiceMocks() {
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@ -137,8 +137,14 @@ function makeClosedWorkspace() {
describe("closed isolated workspace issue routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerServiceMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace());
});

View file

@ -27,6 +27,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
@ -82,6 +83,34 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../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();
app.use(express.json());
@ -90,8 +119,8 @@ function createApp() {
async function installActor(app: express.Express, actor?: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
app.use((req, _res, next) => {
(req as any).actor = actor ?? {
@ -135,6 +164,10 @@ function makeIssue(status: "todo" | "done") {
describe("issue comment reopen routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockReset();
mockIssueService.assertCheckoutOwner.mockReset();
@ -151,6 +184,7 @@ describe("issue comment reopen routes", () => {
mockHeartbeatService.getActiveRunForAgent.mockReset();
mockHeartbeatService.cancelRun.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.resolveByReference.mockReset();
mockLogActivity.mockReset();
mockFeedbackService.listIssueVotesForUser.mockReset();
mockFeedbackService.saveIssueVote.mockReset();
@ -201,6 +235,12 @@ describe("issue comment reopen routes", () => {
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null);
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({
ambiguous: false,
agent: {
id: reference,
},
}));
});
it("treats reopen=true as a no-op when the issue is already open", async () => {
@ -259,6 +299,62 @@ describe("issue comment reopen routes", () => {
);
});
it("resolves assignee shortnames before updating an issue", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("todo"),
...patch,
}));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: { id: "33333333-3333-4333-8333-333333333333" },
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", assigneeAgentId: "codexcoder" });
expect(res.status).toBe(200);
expect(mockAgentService.resolveByReference).toHaveBeenCalledWith("company-1", "codexcoder");
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
}),
);
});
it("rejects ambiguous assignee shortnames", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: true,
agent: null,
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ assigneeAgentId: "codexcoder" });
expect(res.status).toBe(409);
expect(res.body.error).toContain("ambiguous");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects missing assignee shortnames", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: null,
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ assigneeAgentId: "codexcoder" });
expect(res.status).toBe(404);
expect(res.body.error).toBe("Agent not found");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
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>) => ({
@ -334,7 +430,6 @@ describe("issue comment reopen routes", () => {
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),

View file

@ -1,7 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueService = vi.hoisted(() => ({
@ -59,7 +58,11 @@ vi.mock("../services/index.js", () => ({
}),
}));
function createApp() {
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -73,15 +76,17 @@ function createApp() {
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.status ?? 500).json({ error: err?.message ?? "Internal server error" });
});
app.use(errorHandler);
return app;
}
describe("issue dependency wakeups in issue routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.getComment.mockResolvedValue(null);
mockIssueService.getCommentCursor.mockResolvedValue({
@ -137,20 +142,20 @@ describe("issue dependency wakeups in issue routes", () => {
},
]);
const res = await request(createApp()).patch("/api/issues/issue-1").send({ status: "done" });
await new Promise((resolve) => setTimeout(resolve, 0));
const res = await request(await createApp()).patch("/api/issues/issue-1").send({ status: "done" });
expect(res.status).toBe(200);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-2",
expect.objectContaining({
reason: "issue_blockers_resolved",
payload: expect.objectContaining({
issueId: "issue-2",
resolvedBlockerIssueId: "issue-1",
await vi.waitFor(() => {
expect(mockWakeup).toHaveBeenCalledWith(
"agent-2",
expect.objectContaining({
reason: "issue_blockers_resolved",
payload: expect.objectContaining({
issueId: "issue-2",
resolvedBlockerIssueId: "issue-1",
}),
}),
}),
);
);
});
});
it("wakes the parent when all direct children become terminal", async () => {
@ -194,19 +199,19 @@ describe("issue dependency wakeups in issue routes", () => {
childIssueIds: ["child-0", "child-1"],
});
const res = await request(createApp()).patch("/api/issues/child-1").send({ status: "done" });
await new Promise((resolve) => setTimeout(resolve, 0));
const res = await request(await createApp()).patch("/api/issues/child-1").send({ status: "done" });
expect(res.status).toBe(200);
expect(mockWakeup).toHaveBeenCalledWith(
"agent-9",
expect.objectContaining({
reason: "issue_children_completed",
payload: expect.objectContaining({
issueId: "parent-1",
completedChildIssueId: "child-1",
await vi.waitFor(() => {
expect(mockWakeup).toHaveBeenCalledWith(
"agent-9",
expect.objectContaining({
reason: "issue_children_completed",
payload: expect.objectContaining({
issueId: "parent-1",
completedChildIssueId: "child-1",
}),
}),
}),
);
);
});
});
});

View file

@ -1,8 +1,6 @@
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 issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@ -52,7 +50,38 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
function createApp() {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -72,6 +101,11 @@ function createApp() {
describe("issue document revision routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue({
id: issueId,
@ -122,7 +156,7 @@ describe("issue document revision routes", () => {
});
it("returns revision snapshots including title and format", async () => {
const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
expect(res.status).toBe(200);
expect(res.body).toEqual([
@ -136,7 +170,7 @@ describe("issue document revision routes", () => {
});
it("restores a revision through the append-only route and logs the action", async () => {
const res = await request(createApp())
const res = await request(await createApp())
.post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`)
.send({});
@ -168,7 +202,7 @@ describe("issue document revision routes", () => {
});
it("rejects invalid document keys before attempting restore", async () => {
const res = await request(createApp())
const res = await request(await createApp())
.post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`)
.send({});

View file

@ -1,8 +1,6 @@
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 { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({
@ -24,43 +22,49 @@ const mockHeartbeatService = vi.hoisted(() => ({
cancelRun: vi.fn(async () => null),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
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: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
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: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
function createApp() {
async function createApp() {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -80,6 +84,11 @@ function createApp() {
describe("issue execution policy routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.findMentionedAgents.mockResolvedValue([]);
@ -117,7 +126,7 @@ describe("issue execution policy routes", () => {
updatedAt: new Date(),
}));
const res = await request(createApp())
const res = await request(await createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ executionPolicy: policy });

View file

@ -875,6 +875,83 @@ describe("issue execution policy transitions", () => {
// coderAgentId is the returnAssignee, so QA should be selected
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
});
it("skips a self-review-only stage and completes the workflow", () => {
const policy = makePolicy([
{
type: "review",
participants: [{ type: "agent", agentId: coderAgentId }],
},
]);
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: null,
},
policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
commentBody: "Done",
});
expect(result.patch).toMatchObject({
executionState: {
status: "completed",
currentStageType: null,
currentParticipant: null,
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [policy.stages[0].id],
},
});
expect(result.patch.status).toBeUndefined();
expect(result.patch.assigneeAgentId).toBeUndefined();
});
it("skips a self-review-only review stage and advances to approval", () => {
const policy = makePolicy([
{
type: "review",
participants: [{ type: "agent", agentId: coderAgentId }],
},
{
type: "approval",
participants: [{ type: "user", userId: ctoUserId }],
},
]);
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: null,
},
policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
commentBody: "Done",
});
expect(result.patch).toMatchObject({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: ctoUserId,
executionState: {
status: "pending",
currentStageType: "approval",
currentParticipant: { type: "user", userId: ctoUserId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [policy.stages[0].id],
},
});
});
});
describe("changes requested with no return assignee", () => {

View file

@ -50,31 +50,33 @@ const mockRoutineService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
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: () => ({}),
}));
vi.doMock("../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([
@ -95,7 +97,13 @@ async function createApp(actor: Record<string, unknown>) {
describe("issue feedback trace routes", () => {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({
attempted: 1,
sent: 1,

View file

@ -1,8 +1,6 @@
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(),
@ -18,39 +16,41 @@ const mockAgentService = vi.hoisted(() => ({
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
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: () => ({}),
}));
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: () => ({}),
}));
}
function makeIssue(status: "todo" | "done") {
return {
@ -65,7 +65,11 @@ function makeIssue(status: "todo" | "done") {
};
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -79,6 +83,14 @@ function createApp(actor: Record<string, unknown>) {
describe("issue telemetry routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
@ -90,15 +102,16 @@ describe("issue telemetry routes", () => {
}));
});
it("emits task-completed telemetry with the agent role", async () => {
it("emits task-completed telemetry with the agent role, adapter type, and model", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "engineer",
adapterType: "codex_local",
adapterConfig: { model: "claude-sonnet-4-6" },
});
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
@ -112,12 +125,15 @@ describe("issue telemetry routes", () => {
await vi.waitFor(() => {
expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), {
agentRole: "engineer",
agentId: "agent-1",
adapterType: "codex_local",
model: "claude-sonnet-4-6",
});
});
}, 10_000);
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],

View file

@ -1,8 +1,6 @@
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 ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
@ -31,6 +29,10 @@ vi.mock("../services/index.js", () => ({
}),
agentService: () => ({
getById: vi.fn(async () => null),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
})),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
@ -60,7 +62,53 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
function createApp() {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
})),
}),
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: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
async function createApp() {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -101,7 +149,12 @@ function makeIssue(overrides: Record<string, unknown> = {}) {
describe("issue update comment wakeups", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
@ -123,7 +176,7 @@ describe("issue update comment wakeups", () => {
body: "write the whole thing",
});
const res = await request(createApp())
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
assigneeAgentId: ASSIGNEE_AGENT_ID,
@ -170,7 +223,7 @@ describe("issue update comment wakeups", () => {
body: "please revise this",
});
const res = await request(createApp())
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
comment: "please revise this",

View file

@ -1,8 +1,6 @@
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(),
@ -69,7 +67,11 @@ vi.mock("../services/index.js", () => ({
}),
}));
function createApp() {
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -121,7 +123,11 @@ const projectGoal = {
describe("issue goal context routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
@ -174,7 +180,7 @@ describe("issue goal context routes", () => {
});
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");
const res = await request(await createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
expect(res.status).toBe(200);
expect(res.body.goalId).toBe(projectGoal.id);
@ -188,7 +194,7 @@ describe("issue goal context routes", () => {
});
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
const res = await request(createApp()).get(
const res = await request(await createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
@ -220,7 +226,7 @@ describe("issue goal context routes", () => {
blocks: [],
});
const res = await request(createApp()).get(
const res = await request(await createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);

View file

@ -1,8 +1,6 @@
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(),
@ -10,7 +8,7 @@ const mockAgentService = vi.hoisted(() => ({
const mockListServerAdapters = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
vi.mock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
@ -18,7 +16,21 @@ vi.mock("../adapters/index.js", () => ({
listServerAdapters: mockListServerAdapters,
}));
function createApp(actor: Record<string, unknown>) {
function registerModuleMocks() {
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../adapters/index.js", () => ({
listServerAdapters: mockListServerAdapters,
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ llmRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/llms.js")>("../routes/llms.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -32,14 +44,18 @@ function createApp(actor: Record<string, unknown>) {
describe("llm routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../routes/llms.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockListServerAdapters.mockReturnValue([
{ type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" },
]);
});
it("documents timer heartbeats as opt-in for new hires", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: ["company-1"],

View file

@ -50,9 +50,7 @@ vi.mock("../home-paths.js", () => ({
describe("logger translateTime respects TZ environment variable", () => {
beforeEach(() => {
vi.resetModules();
mockTransport.mockClear();
mockPino.mockClear();
vi.clearAllMocks();
});
it("configures pino-pretty with SYS:HH:MM:ss so timestamps honour the TZ env var", async () => {

View file

@ -1,8 +1,6 @@
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(),
@ -35,14 +33,16 @@ const mockBoardAuthService = vi.hoisted(() => ({
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(),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
boardAuthService: () => mockBoardAuthService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
}
function createDbStub() {
const createdInvite = {
@ -99,7 +99,11 @@ function createDbStub() {
};
}
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -121,6 +125,12 @@ function createApp(actor: Record<string, unknown>, db: Record<string, unknown>)
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockAccessService.canUser.mockResolvedValue(false);
mockAgentService.getById.mockReset();
@ -134,7 +144,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
companyId: "company-1",
role: "engineer",
});
const app = createApp(
const app = await createApp(
{
type: "agent",
agentId: "agent-1",
@ -159,7 +169,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
companyId: "company-1",
role: "ceo",
});
const app = createApp(
const app = await createApp(
{
type: "agent",
agentId: "agent-1",
@ -187,7 +197,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("includes companyName in invite summary responses", async () => {
const db = createDbStub();
const app = createApp(
const app = await createApp(
{
type: "board",
userId: "user-1",
@ -209,7 +219,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 = createApp(
const app = await createApp(
{
type: "board",
userId: "user-1",
@ -237,7 +247,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
it("rejects board callers without invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(false);
const app = createApp(
const app = await createApp(
{
type: "board",
userId: "user-1",

View file

@ -1,11 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { 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";
async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
const { privateHostnameGuard } = await import("../middleware/private-hostname-guard.js");
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
const app = express();
app.use(
privateHostnameGuard({
@ -24,39 +24,56 @@ async function createApp(opts: { enabled: boolean; allowedHostnames?: string[];
}
describe("privateHostnameGuard", () => {
beforeEach(() => {
vi.resetModules();
});
it("allows requests when disabled", async () => {
const app = await createApp({ enabled: false });
const app = 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 = await createApp({ enabled: true });
const app = 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 = await createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
const app = 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 = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const app = 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 = 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}`);
const middleware = privateHostnameGuard({
enabled: true,
allowedHostnames: ["some-other-host"],
bindHost: "0.0.0.0",
});
const req = {
path: "/dashboard",
header: (name: string) => (name.toLowerCase() === "host" ? `${unknownHostname}:3100` : undefined),
accepts: () => "html",
} as any;
const res = {
status: vi.fn().mockReturnThis(),
type: vi.fn().mockReturnThis(),
send: vi.fn(),
json: vi.fn(),
} as any;
const next = vi.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith(
expect.stringContaining(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`),
);
}, 20_000);
});

View file

@ -1,9 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { projectRoutes } from "../routes/projects.js";
import { goalRoutes } from "../routes/goals.js";
import { errorHandler } from "../middleware/index.js";
const mockProjectService = vi.hoisted(() => ({
list: vi.fn(),
@ -26,20 +23,8 @@ const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
"@paperclipai/shared/telemetry",
);
return {
...actual,
trackProjectCreated: mockTrackProjectCreated,
trackGoalCreated: mockTrackGoalCreated,
};
});
const mockTelemetryTrack = vi.hoisted(() => vi.fn());
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
@ -58,7 +43,29 @@ vi.mock("../services/workspace-runtime.js", () => ({
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
function createApp(route: ReturnType<typeof projectRoutes> | ReturnType<typeof goalRoutes>) {
function registerModuleMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
goalService: () => mockGoalService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
}
async function createApp(routeType: "project" | "goal") {
const { errorHandler } = await vi.importActual<typeof import("../middleware/index.js")>(
"../middleware/index.js",
);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -71,15 +78,34 @@ function createApp(route: ReturnType<typeof projectRoutes> | ReturnType<typeof g
};
next();
});
app.use("/api", route);
if (routeType === "project") {
const { projectRoutes } = await vi.importActual<typeof import("../routes/projects.js")>(
"../routes/projects.js",
);
app.use("/api", projectRoutes({} as any));
} else {
const { goalRoutes } = await vi.importActual<typeof import("../routes/goals.js")>(
"../routes/goals.js",
);
app.use("/api", goalRoutes({} as any));
}
app.use(errorHandler);
return app;
}
describe("project and goal telemetry routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
vi.resetModules();
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/workspace-runtime.js");
vi.doUnmock("../routes/projects.js");
vi.doUnmock("../routes/goals.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.create.mockResolvedValue({
@ -101,20 +127,22 @@ describe("project and goal telemetry routes", () => {
});
it("emits telemetry when a project is created", async () => {
const res = await request(createApp(projectRoutes({} as any)))
const app = await createApp("project");
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({ name: "Telemetry project" });
expect([200, 201]).toContain(res.status);
expect(mockTrackProjectCreated).toHaveBeenCalledWith(expect.anything());
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockTelemetryTrack).toHaveBeenCalledWith("project.created");
});
it("emits telemetry when a goal is created", async () => {
const res = await request(createApp(goalRoutes({} as any)))
const app = await createApp("goal");
const res = await request(app)
.post("/api/companies/company-1/goals")
.send({ title: "Telemetry goal", level: "team" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockTrackGoalCreated).toHaveBeenCalledWith(expect.anything(), { goalLevel: "team" });
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockTelemetryTrack).toHaveBeenCalledWith("goal.created", { goal_level: "team" });
});
});

View file

@ -21,6 +21,22 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
@ -40,8 +56,10 @@ function registerModuleMocks() {
}
async function createApp() {
const { projectRoutes } = await import("../routes/projects.js");
const { errorHandler } = await import("../middleware/index.js");
const [{ projectRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/projects.js")>("../routes/projects.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -100,8 +118,11 @@ function buildProject(overrides: Record<string, unknown> = {}) {
describe("project env routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/projects.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.createWorkspace.mockResolvedValue(null);
@ -128,7 +149,7 @@ describe("project env routes", () => {
env: normalizedEnv,
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith(
"company-1",
normalizedEnv,

View file

@ -28,51 +28,53 @@ import {
} from "./helpers/embedded-postgres.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");
function registerRoutineServiceMock() {
vi.doMock("../services/routines.js", async () => {
const actual = await vi.importActual<typeof import("../services/routines.js")>("../services/routines.js");
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;
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 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 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;
@ -117,12 +119,28 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/companies.js");
vi.doUnmock("../services/projects.js");
vi.doUnmock("../services/company-skills.js");
vi.doUnmock("../services/assets.js");
vi.doUnmock("../services/agent-instructions.js");
vi.doUnmock("../services/workspace-runtime.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/routines.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRoutineServiceMock();
});
async function createApp(actor: Record<string, unknown>) {
const [{ routineRoutes }, { errorHandler }] = await Promise.all([
import("../routes/routines.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@ -135,6 +153,23 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
return app;
}
async function postRoutineRun(
app: express.Express,
routineId: string,
body: Record<string, unknown>,
) {
let response = await request(app)
.post(`/api/routines/${routineId}/run`)
.send(body);
if (response.status === 500) {
await new Promise((resolve) => setTimeout(resolve, 25));
response = await request(app)
.post(`/api/routines/${routineId}/run`)
.send(body);
}
return response;
}
async function seedFixture() {
const companyId = randomUUID();
const agentId = randomUUID();
@ -202,7 +237,7 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
catchUpPolicy: "skip_missed",
});
expect(createRes.status).toBe(201);
expect([200, 201]).toContain(createRes.status);
expect(createRes.body.title).toBe("Daily standup prep");
expect(createRes.body.assigneeAgentId).toBe(agentId);
@ -217,17 +252,15 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
timezone: "UTC",
});
expect(triggerRes.status).toBe(201);
expect([200, 201], JSON.stringify(triggerRes.body)).toContain(triggerRes.status);
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" },
});
const runRes = await postRoutineRun(app, routineId, {
source: "manual",
payload: { origin: "e2e-test" },
});
expect(runRes.status).toBe(202);
expect(runRes.body.status).toBe("issue_created");
@ -244,8 +277,11 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
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 [persistedRun] = await db
.select({ id: routineRuns.id })
.from(routineRuns)
.where(eq(routineRuns.id, runRes.body.id));
expect(persistedRun?.id).toBe(runRes.body.id);
const [issue] = await db
.select({
@ -303,14 +339,12 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
],
});
expect(createRes.status).toBe(201);
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
variables: { repo: "paperclip" },
});
const runRes = await postRoutineRun(app, createRes.body.id, {
source: "manual",
variables: { repo: "paperclip" },
});
expect(runRes.status).toBe(202);
expect(runRes.body.triggerPayload).toEqual({
@ -345,18 +379,16 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
description: "No saved defaults",
});
expect(createRes.status).toBe(201);
expect(createRes.body.projectId).toBeNull();
expect(createRes.body.assigneeAgentId).toBeNull();
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
expect(createRes.body.projectId ?? null).toBeNull();
expect(createRes.body.assigneeAgentId ?? null).toBeNull();
expect(createRes.body.status).toBe("paused");
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
projectId,
assigneeAgentId: agentId,
});
const runRes = await postRoutineRun(app, createRes.body.id, {
source: "manual",
projectId,
assigneeAgentId: agentId,
});
expect(runRes.status).toBe(202);
expect(runRes.body.status).toBe("issue_created");
@ -428,16 +460,14 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
assigneeAgentId: agentId,
});
expect(createRes.status).toBe(201);
expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status);
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
const runRes = await postRoutineRun(app, createRes.body.id, {
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
expect(runRes.status).toBe(202);

View file

@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { routineRoutes } from "../routes/routines.js";
const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "11111111-1111-4111-8111-111111111111";
@ -85,22 +83,28 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackRoutineCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackRoutineCreated: mockTrackRoutineCreated,
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackRoutineCreated: mockTrackRoutineCreated,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { routineRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -114,6 +118,14 @@ function createApp(actor: Record<string, unknown>) {
describe("routine routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/routines.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockRoutineService.create.mockResolvedValue(routine);
@ -130,7 +142,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission for non-admin board routine creation", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@ -152,7 +164,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission to retarget a routine assignee", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@ -173,7 +185,7 @@ describe("routine routes", () => {
it("requires tasks:assign permission to reactivate a routine", async () => {
mockRoutineService.get.mockResolvedValue(pausedRoutine);
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@ -193,7 +205,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission to create a trigger", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@ -215,7 +227,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission to update a trigger", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@ -235,7 +247,7 @@ describe("routine routes", () => {
});
it("requires tasks:assign permission to manually run a routine", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
@ -254,7 +266,7 @@ describe("routine routes", () => {
it("allows routine creation when the board user has tasks:assign", async () => {
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp({
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",

View file

@ -119,6 +119,13 @@ vi.mock("../services/index.js", () => ({
heartbeatService: vi.fn(() => ({
reapOrphanedRuns: vi.fn(async () => undefined),
resumeQueuedRuns: vi.fn(async () => undefined),
reconcileStrandedAssignedIssues: vi.fn(async () => ({
dispatchRequeued: 0,
continuationRequeued: 0,
escalated: 0,
skipped: 0,
issueIds: [],
})),
tickTimers: vi.fn(async () => ({ enqueued: 0 })),
})),
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),

View file

@ -0,0 +1,91 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js";
function createWatcher() {
const listeners = new Map<string, Set<(file: string) => void>>();
return {
on(event: string, listener: (file: string) => void) {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)?.add(listener);
},
off(event: string, listener: (file: string) => void) {
listeners.get(event)?.delete(listener);
},
emit(event: string, file: string) {
for (const listener of listeners.get(event) ?? []) {
listener(file);
}
},
};
}
describe("createCachedViteHtmlRenderer", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("reuses the injected dev html shell until a watched file changes", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
tempDirs.push(tempDir);
const indexPath = path.join(tempDir, "index.html");
fs.writeFileSync(
indexPath,
'<html><body>v1<script type="module" src="/src/main.tsx"></script></body></html>',
"utf8",
);
const watcher = createWatcher();
const vite: ViteWatcherHost = {
watcher,
};
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir });
await expect(renderer.render("/")).resolves.toContain("/@vite/client");
await expect(renderer.render("/")).resolves.toContain('"/@react-refresh"');
const first = await renderer.render("/");
const second = await renderer.render("/issues");
expect(first).toBe(second);
expect(first.match(/\/@vite\/client/g)?.length).toBe(1);
expect(first).toContain("window.$RefreshReg$");
fs.writeFileSync(
indexPath,
'<html><body>v2<script type="module" src="/src/main.tsx"></script></body></html>',
"utf8",
);
watcher.emit("change", indexPath);
await expect(renderer.render("/")).resolves.toContain("v2");
renderer.dispose();
});
it("does not duplicate the vite client tag or react refresh preamble when already present", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
tempDirs.push(tempDir);
fs.writeFileSync(
path.join(tempDir, "index.html"),
'<html><head><script type="module">import { injectIntoGlobalHook } from "/@react-refresh";injectIntoGlobalHook(window);window.$RefreshReg$ = () => {};window.$RefreshSig$ = () => (type) => type;</script></head><body><script type="module" src="/@vite/client"></script><script type="module" src="/src/main.tsx"></script></body></html>',
"utf8",
);
const vite: ViteWatcherHost = {
watcher: createWatcher(),
};
const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir });
const html = await renderer.render("/");
expect(html.match(/\/@vite\/client/g)?.length).toBe(1);
expect(html.match(/\/@react-refresh/g)?.length).toBe(1);
});
});

View file

@ -50,9 +50,27 @@ import { createPluginHostServiceCleanup } from "./services/plugin-host-service-c
import { pluginRegistryService } from "./services/plugin-registry.js";
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js";
type UiMode = "none" | "static" | "vite-dev";
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
const VITE_DEV_ASSET_PREFIXES = [
"/@fs/",
"/@id/",
"/@react-refresh",
"/@vite/",
"/assets/",
"/node_modules/",
"/src/",
];
const VITE_DEV_STATIC_PATHS = new Set([
"/apple-touch-icon.png",
"/favicon-16x16.png",
"/favicon-32x32.png",
"/favicon.ico",
"/favicon.svg",
"/site.webmanifest",
]);
export function resolveViteHmrPort(serverPort: number): number {
if (serverPort <= 55_535) {
@ -61,6 +79,13 @@ export function resolveViteHmrPort(serverPort: number): number {
return Math.max(1_024, serverPort - 10_000);
}
function shouldServeViteDevHtml(req: ExpressRequest): boolean {
const pathname = req.path;
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false;
return req.accepts(["html"]) === "html";
}
export async function createApp(
db: Db,
opts: {
@ -195,6 +220,7 @@ export async function createApp(
jobStore,
});
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
const loader = pluginLoader(
db,
{ localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR },
@ -287,18 +313,26 @@ export async function createApp(
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
},
});
viteHtmlRenderer = createCachedViteHtmlRenderer({
vite,
uiRoot,
brandHtml: applyUiBranding,
});
const renderViteHtml = viteHtmlRenderer;
app.use(vite.middlewares);
app.get(/.*/, async (req, res, next) => {
if (!shouldServeViteDevHtml(req)) {
next();
return;
}
try {
const templatePath = path.resolve(uiRoot, "index.html");
const template = fs.readFileSync(templatePath, "utf-8");
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
const html = await renderViteHtml.render(req.originalUrl);
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (err) {
next(err);
}
});
app.use(vite.middlewares);
}
app.use(errorHandler);
@ -340,6 +374,7 @@ export async function createApp(
process.once("exit", () => {
if (feedbackExportTimer) clearInterval(feedbackExportTimer);
devWatcher?.close();
viteHtmlRenderer?.dispose();
hostServiceCleanup.disposeAll();
hostServiceCleanup.teardown();
});

View file

@ -583,6 +583,16 @@ export async function startServer(): Promise<StartedServer> {
void heartbeat
.reapOrphanedRuns()
.then(() => heartbeat.resumeQueuedRuns())
.then(async () => {
const reconciled = await heartbeat.reconcileStrandedAssignedIssues();
if (
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.escalated > 0
) {
logger.warn({ ...reconciled }, "startup stranded-issue reconciliation changed assigned issue state");
}
})
.catch((err) => {
logger.error({ err }, "startup heartbeat recovery failed");
});
@ -614,6 +624,16 @@ export async function startServer(): Promise<StartedServer> {
void heartbeat
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 })
.then(() => heartbeat.resumeQueuedRuns())
.then(async () => {
const reconciled = await heartbeat.reconcileStrandedAssignedIssues();
if (
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.escalated > 0
) {
logger.warn({ ...reconciled }, "periodic stranded-issue reconciliation changed assigned issue state");
}
})
.catch((err) => {
logger.error({ err }, "periodic heartbeat recovery failed");
});

View file

@ -0,0 +1,47 @@
const SILENCED_SUCCESS_METHODS = new Set(["GET", "HEAD"]);
const SILENCED_SUCCESS_API_PATHS = [
/^\/api\/health(?:\/|$)/,
/^\/api\/companies\/[^/]+\/activity(?:\/|$)/,
/^\/api\/companies\/[^/]+\/dashboard(?:\/|$)/,
/^\/api\/companies\/[^/]+\/heartbeat-runs(?:\/|$)/,
/^\/api\/companies\/[^/]+\/issues(?:\/|$)/,
/^\/api\/companies\/[^/]+\/live-runs(?:\/|$)/,
/^\/api\/companies\/[^/]+\/sidebar-badges(?:\/|$)/,
/^\/api\/heartbeat-runs\/[^/]+\/log(?:\/|$)/,
];
const SILENCED_SUCCESS_STATIC_PREFIXES = [
"/@fs/",
"/@id/",
"/@react-refresh",
"/@vite/",
"/_plugins/",
"/assets/",
"/node_modules/",
"/src/",
];
const SILENCED_SUCCESS_STATIC_PATHS = new Set([
"/favicon.ico",
"/site.webmanifest",
]);
function normalizePath(url: string): string {
const trimmed = url.trim();
if (trimmed.length === 0) return "/";
const pathname = trimmed.split("?")[0]?.trim() ?? "/";
return pathname.length > 0 ? pathname : "/";
}
export function shouldSilenceHttpSuccessLog(method: string | undefined, url: string | undefined, statusCode: number): boolean {
if (statusCode >= 400) return false;
if (statusCode === 304) return true;
if (!method || !url) return false;
if (!SILENCED_SUCCESS_METHODS.has(method.toUpperCase())) return false;
const pathname = normalizePath(url);
if (SILENCED_SUCCESS_STATIC_PATHS.has(pathname)) return true;
if (SILENCED_SUCCESS_STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return true;
return SILENCED_SUCCESS_API_PATHS.some((pattern) => pattern.test(pathname));
}

View file

@ -4,6 +4,7 @@ import pino from "pino";
import { pinoHttp } from "pino-http";
import { readConfigFile } from "../config-file.js";
import { resolveDefaultLogsDir, resolveHomeAwarePath } from "../home-paths.js";
import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js";
function resolveServerLogDir(): string {
const envOverride = process.env.PAPERCLIP_LOG_DIR?.trim();
@ -47,6 +48,9 @@ export const logger = pino({
export const httpLogger = pinoHttp({
logger,
customLogLevel(_req, res, err) {
if (shouldSilenceHttpSuccessLog(_req.method, _req.url, res.statusCode)) {
return "silent";
}
if (err || res.statusCode >= 500) return "error";
if (res.statusCode >= 400) return "warn";
return "info";

View file

@ -36,6 +36,15 @@ If `PAPERCLIP_APPROVAL_ID` is set:
- Never retry a 409 -- that task belongs to someone else.
- Do the work. Update status and comment when done.
Status quick guide:
- `todo`: ready to execute, but not yet checked out.
- `in_progress`: actively owned work. Agents should reach this by checkout, not by manually flipping status.
- `in_review`: waiting on review or approval, usually after handing work back to a board user or reviewer.
- `blocked`: cannot move until something specific changes. Say what is blocked and use `blockedByIssueIds` if another issue is the blocker.
- `done`: finished.
- `cancelled`: intentionally dropped.
## 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.

View file

@ -587,7 +587,11 @@ export function adapterRoutes() {
// Serve a declarative config schema for an adapter's UI form fields.
// The adapter's getConfigSchema() resolves all options (static and dynamic)
// so the UI receives a fully hydrated schema in a single fetch.
const configSchemaCache = new Map<string, { schema: AdapterConfigSchema; fetchedAt: number }>();
const configSchemaCache = new Map<string, {
adapter: ServerAdapterModule;
schema: AdapterConfigSchema;
fetchedAt: number;
}>();
const CONFIG_SCHEMA_TTL_MS = 30_000;
router.get("/adapters/:type/config-schema", async (req, res) => {
@ -605,14 +609,14 @@ export function adapterRoutes() {
}
const cached = configSchemaCache.get(type);
if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
if (cached && cached.adapter === adapter && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
res.json(cached.schema);
return;
}
try {
const schema = await adapter.getConfigSchema();
configSchemaCache.set(type, { schema, fetchedAt: Date.now() });
configSchemaCache.set(type, { adapter, schema, fetchedAt: Date.now() });
res.json(schema);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

View file

@ -964,6 +964,13 @@ export function agentRoutes(db: Db) {
router.get("/companies/:companyId/agents", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const unsupportedQueryParams = Object.keys(req.query).sort();
if (unsupportedQueryParams.length > 0) {
res.status(400).json({
error: `Unsupported query parameter${unsupportedQueryParams.length === 1 ? "" : "s"}: ${unsupportedQueryParams.join(", ")}`,
});
return;
}
const result = await svc.list(companyId);
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
if (canReadConfigs || req.actor.type === "board") {
@ -1426,7 +1433,7 @@ export function agentRoutes(db: Db) {
});
const telemetryClient = getTelemetryClient();
if (telemetryClient) {
trackAgentCreated(telemetryClient, { agentRole: agent.role });
trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id });
}
await applyDefaultAgentTaskAssignGrant(
@ -1514,7 +1521,7 @@ export function agentRoutes(db: Db) {
});
const telemetryClient = getTelemetryClient();
if (telemetryClient) {
trackAgentCreated(telemetryClient, { agentRole: agent.role });
trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id });
}
await applyDefaultAgentTaskAssignGrant(
@ -2442,7 +2449,13 @@ export function agentRoutes(db: Db) {
assertCompanyAccess(req, issue.companyId);
let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null;
if (run && run.status !== "queued" && run.status !== "running") {
if (
run &&
(
(run.status !== "queued" && run.status !== "running") ||
run.issueId !== issue.id
)
) {
run = null;
}

View file

@ -46,7 +46,7 @@ import {
workProductService,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import {
@ -460,6 +460,28 @@ export function issueRoutes(
return runToInterrupt?.status === "running" ? runToInterrupt : null;
}
async function normalizeIssueAssigneeAgentReference(
companyId: string,
rawAssigneeAgentId: string | null | undefined,
) {
if (rawAssigneeAgentId === undefined || rawAssigneeAgentId === null) {
return rawAssigneeAgentId;
}
const raw = rawAssigneeAgentId.trim();
if (raw.length === 0) {
return rawAssigneeAgentId;
}
const resolved = await agentsSvc.resolveByReference(companyId, raw);
if (resolved.ambiguous) {
throw conflict("Agent shortname is ambiguous in this company. Use the agent ID.");
}
if (!resolved.agent) {
throw notFound("Agent not found");
}
return resolved.agent.id;
}
function toValidTimestamp(value: Date | string | null | undefined) {
if (!value) return null;
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
@ -485,7 +507,6 @@ export function issueRoutes(
if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false;
return commentCreatedAtMs >= activeRunStartedAtMs;
}
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
if (!issue.executionWorkspaceId) return null;
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
@ -1356,6 +1377,10 @@ export function issueRoutes(
const actor = getActorInfo(req);
const isClosed = isClosedIssueStatus(existing.status);
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
existing.companyId,
req.body.assigneeAgentId as string | null | undefined,
);
const existingRelations =
Array.isArray(req.body.blockedByIssueIds)
? await svc.getRelationSummaries(existing.id)
@ -1368,7 +1393,7 @@ export function issueRoutes(
...updateFields
} = req.body;
const requestedAssigneeAgentId =
req.body.assigneeAgentId === undefined ? existing.assigneeAgentId : (req.body.assigneeAgentId as string | null);
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
const effectiveReopenRequested =
reopenRequested ||
(!!commentBody &&
@ -1431,14 +1456,16 @@ export function issueRoutes(
updateFields.executionPolicy !== undefined
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
: previousExecutionPolicy;
if (normalizedAssigneeAgentId !== undefined) {
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
}
const transition = applyIssueExecutionPolicyTransition({
issue: existing,
policy: nextExecutionPolicy,
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
requestedAssigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
assigneeAgentId: normalizedAssigneeAgentId,
assigneeUserId:
req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null),
},
@ -1527,8 +1554,7 @@ export function issueRoutes(
issueId: id,
companyId: existing.companyId,
assigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId,
assigneeAgentId: normalizedAssigneeAgentId === undefined ? "__omitted__" : normalizedAssigneeAgentId,
assigneeUserId:
req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
},
@ -1682,7 +1708,13 @@ export function issueRoutes(
if (tc && actor.agentId) {
const actorAgent = await agentsSvc.getById(actor.agentId);
if (actorAgent) {
trackAgentTaskCompleted(tc, { agentRole: actorAgent.role });
const model = typeof actorAgent.adapterConfig?.model === "string" ? actorAgent.adapterConfig.model : undefined;
trackAgentTaskCompleted(tc, {
agentRole: actorAgent.role,
agentId: actorAgent.id,
adapterType: actorAgent.adapterType,
model,
});
}
}
}
@ -1722,6 +1754,10 @@ export function issueRoutes(
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
const statusChangedFromBlockedToTodo =
existing.status === "blocked" &&
issue.status === "todo" &&
req.body.status !== undefined;
const previousExecutionState = parseIssueExecutionState(existing.executionState);
const nextExecutionState = parseIssueExecutionState(issue.executionState);
const executionStageWakeup = buildExecutionStageWakeup({
@ -1775,7 +1811,7 @@ export function issueRoutes(
});
}
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
if (!assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo) && issue.assigneeAgentId) {
addWakeup(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",

View file

@ -11,6 +11,74 @@ export interface ActivityFilters {
export function activityService(db: Db) {
const issueIdAsText = sql<string>`${issues.id}::text`;
const summarizedUsageJson = sql<Record<string, unknown> | null>`
case
when ${heartbeatRuns.usageJson} is null then null
else jsonb_strip_nulls(jsonb_build_object(
'inputTokens', coalesce(${heartbeatRuns.usageJson} -> 'inputTokens', ${heartbeatRuns.usageJson} -> 'input_tokens'),
'input_tokens', coalesce(${heartbeatRuns.usageJson} -> 'input_tokens', ${heartbeatRuns.usageJson} -> 'inputTokens'),
'outputTokens', coalesce(${heartbeatRuns.usageJson} -> 'outputTokens', ${heartbeatRuns.usageJson} -> 'output_tokens'),
'output_tokens', coalesce(${heartbeatRuns.usageJson} -> 'output_tokens', ${heartbeatRuns.usageJson} -> 'outputTokens'),
'cachedInputTokens', coalesce(
${heartbeatRuns.usageJson} -> 'cachedInputTokens',
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens'
),
'cached_input_tokens', coalesce(
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
${heartbeatRuns.usageJson} -> 'cachedInputTokens',
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens'
),
'cache_read_input_tokens', coalesce(
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens',
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
${heartbeatRuns.usageJson} -> 'cachedInputTokens'
),
'billingType', coalesce(${heartbeatRuns.usageJson} -> 'billingType', ${heartbeatRuns.usageJson} -> 'billing_type'),
'billing_type', coalesce(${heartbeatRuns.usageJson} -> 'billing_type', ${heartbeatRuns.usageJson} -> 'billingType'),
'costUsd', coalesce(
${heartbeatRuns.usageJson} -> 'costUsd',
${heartbeatRuns.usageJson} -> 'cost_usd',
${heartbeatRuns.usageJson} -> 'total_cost_usd'
),
'cost_usd', coalesce(
${heartbeatRuns.usageJson} -> 'cost_usd',
${heartbeatRuns.usageJson} -> 'costUsd',
${heartbeatRuns.usageJson} -> 'total_cost_usd'
),
'total_cost_usd', coalesce(
${heartbeatRuns.usageJson} -> 'total_cost_usd',
${heartbeatRuns.usageJson} -> 'cost_usd',
${heartbeatRuns.usageJson} -> 'costUsd'
)
))
end
`.as("usageJson");
const summarizedResultJson = sql<Record<string, unknown> | null>`
case
when ${heartbeatRuns.resultJson} is null then null
else jsonb_strip_nulls(jsonb_build_object(
'billingType', coalesce(${heartbeatRuns.resultJson} -> 'billingType', ${heartbeatRuns.resultJson} -> 'billing_type'),
'billing_type', coalesce(${heartbeatRuns.resultJson} -> 'billing_type', ${heartbeatRuns.resultJson} -> 'billingType'),
'costUsd', coalesce(
${heartbeatRuns.resultJson} -> 'costUsd',
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'total_cost_usd'
),
'cost_usd', coalesce(
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'costUsd',
${heartbeatRuns.resultJson} -> 'total_cost_usd'
),
'total_cost_usd', coalesce(
${heartbeatRuns.resultJson} -> 'total_cost_usd',
${heartbeatRuns.resultJson} -> 'cost_usd',
${heartbeatRuns.resultJson} -> 'costUsd'
)
))
end
`.as("resultJson");
return {
list: (filters: ActivityFilters) => {
const conditions = [eq(activityLog.companyId, filters.companyId)];
@ -71,8 +139,8 @@ export function activityService(db: Db) {
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
invocationSource: heartbeatRuns.invocationSource,
usageJson: heartbeatRuns.usageJson,
resultJson: heartbeatRuns.resultJson,
usageJson: summarizedUsageJson,
resultJson: summarizedResultJson,
logBytes: heartbeatRuns.logBytes,
})
.from(heartbeatRuns)

View file

@ -4388,7 +4388,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
billingCode: manifestIssue.billingCode,
assigneeAdapterOverrides: manifestIssue.assigneeAdapterOverrides,
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
labelIds: [],
labelIds: manifestIssue.labelIds ?? [],
});
}
}

View file

@ -10,6 +10,7 @@ import {
agentRuntimeState,
agentTaskSessions,
agentWakeupRequests,
companySkills as companySkillsTable,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
@ -68,8 +69,14 @@ import {
resolveSessionCompactionPolicy,
type SessionCompactionPolicy,
} from "@paperclipai/adapter-utils";
import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { extractSkillMentionIds } from "@paperclipai/shared";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
@ -84,6 +91,7 @@ const MAX_INLINE_WAKE_COMMENTS = 8;
const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000;
const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000;
const execFile = promisify(execFileCallback);
const ACTIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"] as const;
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
@ -92,6 +100,7 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"opencode_local",
"pi_local",
]);
const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64","data":")([A-Za-z0-9+/=]{1024,})(")/g;
type RuntimeConfigSecretResolver = Pick<
ReturnType<typeof secretService>,
@ -123,6 +132,90 @@ export async function resolveExecutionRunAdapterConfig(input: {
return { resolvedConfig, secretKeys };
}
export function extractMentionedSkillIdsFromSources(
sources: Array<string | null | undefined>,
): string[] {
const mentionedIds = new Set<string>();
for (const source of sources) {
if (typeof source !== "string" || source.length === 0) continue;
for (const skillId of extractSkillMentionIds(source)) {
mentionedIds.add(skillId);
}
}
return [...mentionedIds];
}
export function applyRunScopedMentionedSkillKeys(
config: Record<string, unknown>,
skillKeys: string[],
): Record<string, unknown> {
const normalizedSkillKeys = Array.from(
new Set(
skillKeys
.map((value) => value.trim())
.filter(Boolean),
),
);
if (normalizedSkillKeys.length === 0) return config;
const existingPreference = readPaperclipSkillSyncPreference(config);
return writePaperclipSkillSyncPreference(config, [
...existingPreference.desiredSkills,
...normalizedSkillKeys,
]);
}
async function resolveRunScopedMentionedSkillKeys(input: {
db: Db;
companyId: string;
issueId: string | null;
}): Promise<string[]> {
if (!input.issueId) return [];
const issue = await input.db
.select({
title: issues.title,
description: issues.description,
})
.from(issues)
.where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)))
.then((rows) => rows[0] ?? null);
if (!issue) return [];
const comments = await input.db
.select({ body: issueComments.body })
.from(issueComments)
.where(
and(
eq(issueComments.issueId, input.issueId),
eq(issueComments.companyId, input.companyId),
),
);
const mentionedSkillIds = extractMentionedSkillIdsFromSources([
issue.title,
issue.description ?? "",
...comments.map((comment) => comment.body),
]);
if (mentionedSkillIds.length === 0) return [];
const skillRows = await input.db
.select({
id: companySkillsTable.id,
key: companySkillsTable.key,
})
.from(companySkillsTable)
.where(
and(
eq(companySkillsTable.companyId, input.companyId),
inArray(companySkillsTable.id, mentionedSkillIds),
),
);
const skillKeyById = new Map(skillRows.map((row) => [row.id, row.key]));
return mentionedSkillIds
.map((skillId) => skillKeyById.get(skillId) ?? null)
.filter((skillKey): skillKey is string => Boolean(skillKey));
}
export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
@ -323,6 +416,23 @@ function appendExcerpt(prev: string, chunk: string) {
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
}
function redactInlineBase64ImageData(chunk: string) {
return chunk.replace(INLINE_BASE64_IMAGE_DATA_RE, (_match, prefix: string, data: string, suffix: string) =>
`${prefix}[omitted base64 image data: ${data.length} chars]${suffix}`,
);
}
export function compactRunLogChunk(chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS) {
const normalized = redactInlineBase64ImageData(chunk);
if (normalized.length <= maxChars) return normalized;
const headChars = Math.max(0, Math.floor(maxChars * 0.6));
const tailChars = Math.max(0, Math.floor(maxChars * 0.25));
const omittedChars = Math.max(0, normalized.length - headChars - tailChars);
const marker = `\n[paperclip truncated run log chunk: omitted ${omittedChars} chars]\n`;
return `${normalized.slice(0, headChars)}${marker}${normalized.slice(normalized.length - tailChars)}`;
}
function normalizeMaxConcurrentRuns(value: unknown) {
const parsed = Math.floor(asNumber(value, HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT));
if (!Number.isFinite(parsed)) return HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT;
@ -2427,7 +2537,7 @@ export function heartbeatService(db: Db) {
if (isFirstHeartbeat && updated) {
const tc = getTelemetryClient();
if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role });
if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role, agentId: updated.id });
}
if (updated) {
@ -2569,6 +2679,256 @@ export function heartbeatService(db: Db) {
}
}
async function getLatestIssueRun(companyId: string, issueId: string) {
return db
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
),
)
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function hasActiveExecutionPath(companyId: string, issueId: string) {
const [run, deferredWake] = await Promise.all([
db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, [...ACTIVE_HEARTBEAT_RUN_STATUSES]),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null),
]);
return Boolean(run || deferredWake);
}
async function enqueueStrandedIssueRecovery(input: {
issueId: string;
agentId: string;
reason: "issue_assignment_recovery" | "issue_continuation_needed";
retryReason: "assignment_recovery" | "issue_continuation_needed";
source: string;
retryOfRunId?: string | null;
}) {
const queued = await enqueueWakeup(input.agentId, {
source: "automation",
triggerDetail: "system",
reason: input.reason,
payload: {
issueId: input.issueId,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
issueId: input.issueId,
taskId: input.issueId,
wakeReason: input.reason,
retryReason: input.retryReason,
source: input.source,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
});
if (queued && input.retryOfRunId) {
return db
.update(heartbeatRuns)
.set({
retryOfRunId: input.retryOfRunId,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, queued.id))
.returning()
.then((rows) => rows[0] ?? queued);
}
return queued;
}
async function escalateStrandedAssignedIssue(input: {
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
latestRun: typeof heartbeatRuns.$inferSelect | null;
comment: string;
}) {
const updated = await issuesSvc.update(input.issue.id, {
status: "blocked",
});
if (!updated) return null;
await issuesSvc.addComment(input.issue.id, input.comment, {});
await logActivity(db, {
companyId: input.issue.companyId,
actorType: "system",
actorId: "system",
agentId: null,
runId: null,
action: "issue.updated",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
status: "blocked",
previousStatus: input.previousStatus,
source: "heartbeat.reconcile_stranded_assigned_issue",
latestRunId: input.latestRun?.id ?? null,
latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null,
},
});
return updated;
}
async function reconcileStrandedAssignedIssues() {
const candidates = await db
.select()
.from(issues)
.where(
and(
isNull(issues.assigneeUserId),
inArray(issues.status, ["todo", "in_progress"]),
sql`${issues.assigneeAgentId} is not null`,
),
);
const result = {
dispatchRequeued: 0,
continuationRequeued: 0,
escalated: 0,
skipped: 0,
issueIds: [] as string[],
};
for (const issue of candidates) {
const agentId = issue.assigneeAgentId;
if (!agentId) {
result.skipped += 1;
continue;
}
const agent = await getAgent(agentId);
if (!agent || agent.companyId !== issue.companyId) {
result.skipped += 1;
continue;
}
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
result.skipped += 1;
continue;
}
if (await hasActiveExecutionPath(issue.companyId, issue.id)) {
result.skipped += 1;
continue;
}
const latestRun = await getLatestIssueRun(issue.companyId, issue.id);
const latestContext = parseObject(latestRun?.contextSnapshot);
const latestRetryReason = readNonEmptyString(latestContext.retryReason);
if (issue.status === "todo") {
if (!latestRun || latestRun.status === "succeeded") {
result.skipped += 1;
continue;
}
if (latestRetryReason === "assignment_recovery") {
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "todo",
latestRun,
comment:
"Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " +
"but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.",
});
if (updated) {
result.escalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
const queued = await enqueueStrandedIssueRecovery({
issueId: issue.id,
agentId,
reason: "issue_assignment_recovery",
retryReason: "assignment_recovery",
source: "issue.assignment_recovery",
retryOfRunId: latestRun.id,
});
if (queued) {
result.dispatchRequeued += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
if (latestRetryReason === "issue_continuation_needed") {
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun,
comment:
"Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " +
"execution disappeared, but it still has no live execution path. Moving it to `blocked` so it is " +
"visible for intervention.",
});
if (updated) {
result.escalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
const queued = await enqueueStrandedIssueRecovery({
issueId: issue.id,
agentId,
reason: "issue_continuation_needed",
retryReason: "issue_continuation_needed",
source: "issue.continuation_recovery",
retryOfRunId: latestRun?.id ?? issue.checkoutRunId ?? null,
});
if (queued) {
result.continuationRequeued += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
}
return result;
}
async function updateRuntimeState(
agent: typeof agents.$inferSelect,
run: typeof heartbeatRuns.$inferSelect,
@ -2846,9 +3206,18 @@ export function heartbeatService(db: Db) {
projectEnv: projectContext?.env ?? null,
secretsSvc,
});
const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({
db,
companyId: agent.companyId,
issueId,
});
const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys(
resolvedConfig,
runScopedMentionedSkillKeys,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
...effectiveResolvedConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
@ -3183,7 +3552,9 @@ export function heartbeatService(db: Db) {
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
const sanitizedChunk = compactRunLogChunk(
redactCurrentUserText(chunk, currentUserRedactionOptions),
);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString();
@ -3214,6 +3585,12 @@ export function heartbeatService(db: Db) {
},
});
};
if (runScopedMentionedSkillKeys.length > 0) {
await onLog(
"stdout",
`[paperclip] Enabled run-scoped skills from issue mentions: ${runScopedMentionedSkillKeys.join(", ")}\n`,
);
}
for (const warning of runtimeWorkspaceWarnings) {
const logEntry = formatRuntimeWorkspaceWarningLog(warning);
await onLog(logEntry.stream, logEntry.chunk);
@ -3234,7 +3611,7 @@ export function heartbeatService(db: Db) {
issue: issueRef,
workspace: executionWorkspace,
executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
config: resolvedConfig,
config: effectiveResolvedConfig,
adapterEnv,
onLog,
});
@ -3960,11 +4337,10 @@ export function heartbeatService(db: Db) {
return null;
}
const bypassIssueExecutionLock =
reason === "issue_comment_mentioned" ||
readNonEmptyString(enrichedContextSnapshot.wakeReason) === "issue_comment_mentioned";
if (issueId && !bypassIssueExecutionLock) {
if (issueId) {
// Mention-triggered wakes can request input from another agent, but they must
// still respect the issue execution lock so a second agent cannot start on the
// same issue workspace while the assignee already has a live run.
const agentNameKey = normalizeAgentNameKey(agent.name);
const outcome = await db.transaction(async (tx) => {
@ -4700,6 +5076,8 @@ export function heartbeatService(db: Db) {
resumeQueuedRuns,
reconcileStrandedAssignedIssues,
tickTimers: async (now = new Date()) => {
const allAgents = await db.select().from(agents);
let checked = 0;

View file

@ -174,6 +174,42 @@ function buildCompletedState(previous: IssueExecutionState | null, currentStage:
};
}
function buildStateWithCompletedStages(input: {
previous: IssueExecutionState | null;
completedStageIds: string[];
returnAssignee: IssueExecutionStagePrincipal | null;
}): IssueExecutionState {
return {
status: input.previous?.status ?? PENDING_STATUS,
currentStageId: input.previous?.currentStageId ?? null,
currentStageIndex: input.previous?.currentStageIndex ?? null,
currentStageType: input.previous?.currentStageType ?? null,
currentParticipant: input.previous?.currentParticipant ?? null,
returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee,
completedStageIds: input.completedStageIds,
lastDecisionId: input.previous?.lastDecisionId ?? null,
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
};
}
function buildSkippedStageCompletedState(input: {
previous: IssueExecutionState | null;
completedStageIds: string[];
returnAssignee: IssueExecutionStagePrincipal | null;
}): IssueExecutionState {
return {
status: COMPLETED_STATUS,
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee,
completedStageIds: input.completedStageIds,
lastDecisionId: input.previous?.lastDecisionId ?? null,
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
};
}
function buildPendingState(input: {
previous: IssueExecutionState | null;
stage: IssueExecutionStage;
@ -236,6 +272,18 @@ function clearExecutionStatePatch(input: {
}
}
function canAutoSkipPendingStage(input: {
stage: IssueExecutionStage;
returnAssignee: IssueExecutionStagePrincipal | null;
requestedStatus?: string;
}) {
if (input.requestedStatus !== "done" || input.stage.type !== "review" || !input.returnAssignee) {
return false;
}
return input.stage.participants.length > 0 &&
input.stage.participants.every((participant) => principalsEqual(participant, input.returnAssignee));
}
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
const patch: Record<string, unknown> = {};
const existingState = parseIssueExecutionState(input.issue.executionState);
@ -431,27 +479,61 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
return { patch };
}
const pendingStage =
let pendingStage =
existingState?.status === CHANGES_REQUESTED_STATUS && currentStage
? currentStage
: nextPendingStage(input.policy, existingState);
if (!pendingStage) return { patch };
const returnAssignee = existingState?.returnAssignee ?? currentAssignee;
const participant = selectStageParticipant(pendingStage, {
const skippedStageIds = [...(existingState?.completedStageIds ?? [])];
let participant = selectStageParticipant(pendingStage, {
preferred:
existingState?.status === CHANGES_REQUESTED_STATUS
? explicitAssignee ?? existingState.currentParticipant ?? null
: explicitAssignee,
exclude: returnAssignee,
});
while (!participant && canAutoSkipPendingStage({ stage: pendingStage, returnAssignee, requestedStatus })) {
skippedStageIds.push(pendingStage.id);
pendingStage = nextPendingStage(
input.policy,
buildStateWithCompletedStages({
previous: existingState,
completedStageIds: skippedStageIds,
returnAssignee,
}),
);
if (!pendingStage) {
patch.executionState = buildSkippedStageCompletedState({
previous: existingState,
completedStageIds: skippedStageIds,
returnAssignee,
});
return { patch };
}
participant = selectStageParticipant(pendingStage, {
preferred:
existingState?.status === CHANGES_REQUESTED_STATUS
? explicitAssignee ?? existingState.currentParticipant ?? null
: explicitAssignee,
exclude: returnAssignee,
});
}
if (!participant) {
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
}
buildPendingStagePatch({
patch,
previous: existingState,
previous:
skippedStageIds.length === (existingState?.completedStageIds ?? []).length
? existingState
: buildStateWithCompletedStages({
previous: existingState,
completedStageIds: skippedStageIds,
returnAssignee,
}),
policy: input.policy,
stage: pendingStage,
participant,

View file

@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
type ViteWatcherEvent = "add" | "change" | "unlink";
export interface ViteWatcherHost {
watcher?: {
on?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown;
off?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown;
};
}
export interface CachedViteHtmlRenderer {
render(_url: string): Promise<string>;
dispose(): void;
}
const WATCHER_EVENTS: ViteWatcherEvent[] = ["add", "change", "unlink"];
const MAIN_ENTRY_TAG = '<script type="module" src="/src/main.tsx"></script>';
const VITE_CLIENT_TAG = '<script type="module" src="/@vite/client"></script>';
const REACT_REFRESH_PREAMBLE = `<script type="module">
import { injectIntoGlobalHook } from "/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
</script>`;
function injectViteDevPreamble(html: string): string {
let injectedHtml = html;
if (!injectedHtml.includes('"/@react-refresh"') && !injectedHtml.includes("'/@react-refresh'")) {
injectedHtml = injectedHtml.includes("</head>")
? injectedHtml.replace("</head>", ` ${REACT_REFRESH_PREAMBLE}\n </head>`)
: `${REACT_REFRESH_PREAMBLE}\n${injectedHtml}`;
}
if (injectedHtml.includes(VITE_CLIENT_TAG)) return injectedHtml;
if (injectedHtml.includes(MAIN_ENTRY_TAG)) {
return injectedHtml.replace(MAIN_ENTRY_TAG, `${VITE_CLIENT_TAG}\n ${MAIN_ENTRY_TAG}`);
}
return injectedHtml.replace("</body>", ` ${VITE_CLIENT_TAG}\n </body>`);
}
export function createCachedViteHtmlRenderer(opts: {
vite: ViteWatcherHost;
uiRoot: string;
brandHtml?: (html: string) => string;
}): CachedViteHtmlRenderer {
const uiRoot = path.resolve(opts.uiRoot);
const templatePath = path.resolve(uiRoot, "index.html");
const brandHtml = opts.brandHtml ?? ((html: string) => html);
let cachedHtml: string | null = null;
function loadHtml(): string {
if (cachedHtml === null) {
const rawTemplate = fs.readFileSync(templatePath, "utf-8");
cachedHtml = injectViteDevPreamble(brandHtml(rawTemplate));
}
return cachedHtml;
}
function invalidate(): void {
cachedHtml = null;
}
function onWatchEvent(filePath: string): void {
const resolvedPath = path.resolve(filePath);
if (resolvedPath === templatePath || resolvedPath.startsWith(`${uiRoot}${path.sep}`)) {
invalidate();
}
}
for (const eventName of WATCHER_EVENTS) {
opts.vite.watcher?.on?.(eventName, onWatchEvent);
}
return {
render(): Promise<string> {
return Promise.resolve(loadHtml());
},
dispose(): void {
for (const eventName of WATCHER_EVENTS) {
opts.vite.watcher?.off?.(eventName, onWatchEvent);
}
},
};
}