Files
PaperClipAI/server/src/__tests__/costs-service.test.ts
T
Nicky LeachandPaperclip b7fd6c59b6 fix(server): load the costs-service route module graph once per file (#12375)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server test suite checks budget and cost routes
> - The costs-service test rebuilt the full route module graph before
every test
> - Synchronous graph rebuilds caused long stalls under CPU load
> - This pull request loads the mocked graph once for each describe
block and keeps per-test mock setup
> - The benefit is a stable 17-test file without production code changes

## Linked Issues or Issue Description

**What happened?**

The costs-service route test rebuilt its full mocked module graph before
every test. Under CPU load, a rebuild sometimes stalled a test past the
15-second timeout.

**Expected behavior**

The test file should load its mocked route graph once for each describe
block while each test keeps isolated mock behavior.

**Steps to reproduce**

1. Run the costs-service route test under synthetic CPU load.
2. Repeat the file test 30 times.
3. Observe intermittent test timeouts before this change.

**Paperclip version or commit**

The test used the current master branch at the time of this change.

**Deployment mode**

Built from source.

## What Changed

- Add `hoistModuleGraph` to load the mocked route graph once for each
describe block.
- Keep per-test mock setup in `beforeEach` so test isolation stays
unchanged.
- Keep all 17 tests and their assertions.
- Remove the module graph rebuild from the per-test path.

## Verification

- Run `npx vitest run src/__tests__/costs-service.test.ts` from
`server/`.
- Confirm that the file reports 17 tests and zero skipped tests.
- Confirm that 30 runs under the same synthetic CPU load report 0.0%
failure after the change, compared with 10.0% before the change.
- Confirm that mutation checks still fail when each authorization guard
is broken.

## Risks

This change affects test setup only. The main risk is weaker test
isolation if a mock keeps state between tests. Each test still re-arms
its mock behavior in `beforeEach`, and the full assertion set remains.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. This bug fix does not add a
core feature.

## Model Used

OpenAI GPT-5. The model used tool calls and code execution. The exact
context window and reasoning configuration were not exposed by the
runtime.

## 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)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (the submitting engineer
ran the file before handoff)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-27 18:37:30 -07:00

937 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import {
createDb,
companies,
agents,
activityLog,
costEvents,
financeEvents,
heartbeatRuns,
issues,
projects,
} from "@paperclipai/db";
import { costService } from "../services/costs.ts";
import { financeService } from "../services/finance.ts";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
function makeDb(overrides: Record<string, unknown> = {}) {
const selectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([]),
};
const thenableChain = Object.assign(Promise.resolve([]), selectChain);
return {
select: vi.fn().mockReturnValue(thenableChain),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
...overrides,
};
}
const mockCompanyService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
cancelBudgetScopeWork: vi.fn().mockResolvedValue(undefined),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockFetchAllQuotaWindows = vi.hoisted(() => vi.fn());
const mockCostService = vi.hoisted(() => ({
createEvent: vi.fn(),
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
byAgent: vi.fn().mockResolvedValue([]),
byAgentModel: vi.fn().mockResolvedValue([]),
byProvider: vi.fn().mockResolvedValue([]),
byBiller: vi.fn().mockResolvedValue([]),
issueTreeSummary: vi.fn().mockResolvedValue({
issueId: "issue-1",
issueCount: 1,
includeDescendants: true,
costCents: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
runCount: 0,
runtimeMs: 0,
}),
windowSpend: vi.fn().mockResolvedValue([]),
byProject: vi.fn().mockResolvedValue([]),
}));
const mockFinanceService = vi.hoisted(() => ({
createEvent: vi.fn(),
summary: vi.fn().mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0 }),
byBiller: vi.fn().mockResolvedValue([]),
byKind: vi.fn().mockResolvedValue([]),
list: vi.fn().mockResolvedValue([]),
}));
const mockBudgetService = vi.hoisted(() => ({
overview: vi.fn().mockResolvedValue({
companyId: "company-1",
policies: [],
activeIncidents: [],
pausedAgentCount: 0,
pausedProjectCount: 0,
pendingApprovalCount: 0,
}),
upsertPolicy: vi.fn(),
resolveIncident: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
}));
vi.doMock("../services/quota-windows.js", () => ({
fetchAllQuotaWindows: mockFetchAllQuotaWindows,
}));
}
describe("cost routes", () => {
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
const [costsRouteModule, middlewareModule] = await Promise.all([
vi.importActual<typeof import("../routes/costs.js")>("../routes/costs.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
return { ...costsRouteModule, errorHandler: middlewareModule.errorHandler };
});
function createApp() {
const { costRoutes, errorHandler } = routeModules.value;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", userId: "board-user", source: "local_implicit" };
next();
});
app.use("/api", costRoutes(makeDb() as any));
app.use(errorHandler);
return app;
}
function createAppWithActor(actor: any) {
const { costRoutes, errorHandler } = routeModules.value;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", costRoutes(makeDb() as any));
app.use(errorHandler);
return app;
}
function loadCostParsers() {
const { parseCostDateRange, parseCostLimit } = routeModules.value;
return { parseCostDateRange, parseCostLimit };
}
beforeEach(() => {
vi.clearAllMocks();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockCompanyService.update.mockResolvedValue({
id: "company-1",
name: "Paperclip",
budgetMonthlyCents: 100,
spentMonthlyCents: 0,
});
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
name: "Budget Agent",
budgetMonthlyCents: 100,
spentMonthlyCents: 0,
});
mockAgentService.update.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
name: "Budget Agent",
budgetMonthlyCents: 100,
spentMonthlyCents: 0,
});
mockIssueService.getById.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PC1A2-1",
});
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1",
companyId: "company-1",
identifier: "PC1A2-1",
});
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
});
it("accepts valid ISO date strings", async () => {
const { parseCostDateRange } = loadCostParsers();
expect(parseCostDateRange({
from: "2026-01-01T00:00:00.000Z",
to: "2026-01-31T23:59:59.999Z",
})).toEqual({
from: new Date("2026-01-01T00:00:00.000Z"),
to: new Date("2026-01-31T23:59:59.999Z"),
});
});
it("returns 400 for an invalid 'from' date string", async () => {
const { parseCostDateRange } = loadCostParsers();
expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i);
});
it("returns 400 for an invalid 'to' date string", async () => {
const { parseCostDateRange } = loadCostParsers();
expect(() => parseCostDateRange({ to: "banana" })).toThrow(/invalid 'to' date/i);
});
it("returns finance summary rows for valid requests", async () => {
const app = createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/finance-summary")
.query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" });
expect(res.status).toBe(200);
expect(res.body).toEqual({
debitCents: 0,
creditCents: 0,
netCents: 0,
estimatedDebitCents: 0,
eventCount: 0,
});
});
it("returns issue subtree cost summaries for issue refs", async () => {
const app = createApp();
const res = await request(app).get("/api/issues/pc1a2-1/cost-summary");
expect(res.status).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1");
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1", {
excludeRoot: false,
});
expect(res.body).toEqual({
issueId: "issue-1",
issueCount: 1,
includeDescendants: true,
costCents: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
runCount: 0,
runtimeMs: 0,
});
});
it("returns 400 for invalid finance event list limits", async () => {
const { parseCostLimit } = loadCostParsers();
expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i);
});
it("accepts valid finance event list limits", async () => {
const { parseCostLimit } = loadCostParsers();
expect(parseCostLimit({ limit: "25" })).toBe(25);
});
it("rejects company budget updates for board users outside the company", async () => {
const app = createAppWithActor({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-2"],
});
const res = await request(app)
.patch("/api/companies/company-1/budgets")
.send({ budgetMonthlyCents: 2500 });
expect(res.status).toBe(403);
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
it("rejects agent budget updates for board users outside the agent company", async () => {
const app = createAppWithActor({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-2"],
});
const res = await request(app)
.patch("/api/agents/agent-1/budgets")
.send({ budgetMonthlyCents: 2500 });
expect(res.status).toBe(404);
expect(res.body.error).toBe("Agent not found");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("rejects agent budget updates from the target agent without changing the budget policy", async () => {
const app = createAppWithActor({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: "run-1",
});
const res = await request(app)
.patch("/api/agents/agent-1/budgets")
.send({ budgetMonthlyCents: 2500 });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "Board access required" });
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("rejects agent budget updates from another same-company agent without changing the budget policy", async () => {
const app = createAppWithActor({
type: "agent",
agentId: "agent-2",
companyId: "company-1",
runId: "run-2",
});
const res = await request(app)
.patch("/api/agents/agent-1/budgets")
.send({ budgetMonthlyCents: 2500 });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "Board access required" });
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("allows authorized board users to update an agent budget and budget policy", async () => {
mockAgentService.update.mockResolvedValueOnce({
id: "agent-1",
companyId: "company-1",
name: "Budget Agent",
budgetMonthlyCents: 2500,
spentMonthlyCents: 0,
});
const app = createAppWithActor({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
});
const res = await request(app)
.patch("/api/agents/agent-1/budgets")
.send({ budgetMonthlyCents: 2500 });
expect(res.status).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith("agent-1", { budgetMonthlyCents: 2500 });
expect(mockBudgetService.upsertPolicy).toHaveBeenCalledWith(
"company-1",
{
scopeType: "agent",
scopeId: "agent-1",
amount: 2500,
windowKind: "calendar_month_utc",
},
"board-user",
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
actorType: "user",
actorId: "board-user",
agentId: null,
action: "agent.budget_updated",
entityType: "agent",
entityId: "agent-1",
details: { budgetMonthlyCents: 2500 },
}),
);
});
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
let db!: ReturnType<typeof createDb>;
let costs!: ReturnType<typeof costService>;
let finance!: ReturnType<typeof financeService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-costs-service-");
db = createDb(tempDb.connectionString);
costs = costService(db);
finance = financeService(db);
}, 20_000);
afterEach(async () => {
await db.delete(financeEvents);
await db.delete(costEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("persists unpriced token usage without inflating monthly spend", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CLI Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const event = await costs.createEvent(companyId, {
agentId,
provider: "openai",
biller: "chatgpt",
billingType: "subscription_included",
costStatus: "unpriced",
model: "gpt-5.6-terra",
inputTokens: 2_732_577,
cachedInputTokens: 2_632_998,
outputTokens: 32_644,
costCents: 0,
occurredAt: new Date("2026-07-13T14:22:54.000Z"),
});
expect(event.costStatus).toBe("unpriced");
expect(event.inputTokens).toBe(2_732_577);
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(agent?.spentMonthlyCents).toBe(0);
});
it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = 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: "Cost Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Overflow Project",
status: "active",
});
await db.insert(costEvents).values([
{
companyId,
agentId,
projectId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5",
inputTokens: 2_000_000_000,
cachedInputTokens: 0,
outputTokens: 200_000_000,
costCents: 2_000_000_000,
occurredAt: new Date("2026-04-10T00:00:00.000Z"),
},
{
companyId,
agentId,
projectId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5",
inputTokens: 2_000_000_000,
cachedInputTokens: 10,
outputTokens: 200_000_000,
costCents: 2_000_000_000,
occurredAt: new Date("2026-04-11T00:00:00.000Z"),
},
]);
const range = {
from: new Date("2026-04-01T00:00:00.000Z"),
to: new Date("2026-04-15T23:59:59.999Z"),
};
const [byAgentRow] = await costs.byAgent(companyId, range);
const [byProjectRow] = await costs.byProject(companyId, range);
const [byAgentModelRow] = await costs.byAgentModel(companyId, range);
expect(byAgentRow?.costCents).toBe(4_000_000_000);
expect(byAgentRow?.inputTokens).toBe(4_000_000_000);
expect(byProjectRow?.costCents).toBe(4_000_000_000);
expect(byAgentModelRow?.costCents).toBe(4_000_000_000);
});
it("aggregates issue costs across recursive descendants only", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const grandchildIssueId = randomUUID();
const harnessIssueId = randomUUID();
const siblingIssueId = 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: "Cost Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "in_progress",
priority: "medium",
issueNumber: 1,
identifier: "TST-1",
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Child",
status: "done",
priority: "medium",
issueNumber: 2,
identifier: "TST-2",
},
{
id: grandchildIssueId,
companyId,
parentId: childIssueId,
title: "Grandchild",
status: "done",
priority: "medium",
issueNumber: 3,
identifier: "TST-3",
},
{
id: harnessIssueId,
companyId,
parentId: rootIssueId,
title: "Hidden skill test harness",
status: "done",
priority: "medium",
issueNumber: 5,
identifier: "TST-5",
workMode: "skill_test",
harnessKind: "skill_test",
},
{
id: siblingIssueId,
companyId,
title: "Sibling",
status: "done",
priority: "medium",
issueNumber: 4,
identifier: "TST-4",
},
]);
await db.insert(costEvents).values([
{
companyId,
agentId,
issueId: rootIssueId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5",
inputTokens: 10,
cachedInputTokens: 1,
outputTokens: 2,
costCents: 100,
occurredAt: new Date("2026-04-10T00:00:00.000Z"),
},
{
companyId,
agentId,
issueId: childIssueId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5",
inputTokens: 20,
cachedInputTokens: 2,
outputTokens: 4,
costCents: 200,
occurredAt: new Date("2026-04-10T00:01:00.000Z"),
},
{
companyId,
agentId,
issueId: grandchildIssueId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5",
inputTokens: 30,
cachedInputTokens: 3,
outputTokens: 6,
costCents: 300,
occurredAt: new Date("2026-04-10T00:02:00.000Z"),
},
{
companyId,
agentId,
issueId: siblingIssueId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5",
inputTokens: 40,
cachedInputTokens: 4,
outputTokens: 8,
costCents: 400,
occurredAt: new Date("2026-04-10T00:03:00.000Z"),
},
]);
const summary = await costs.issueTreeSummary(companyId, rootIssueId);
expect(summary).toEqual({
issueId: rootIssueId,
issueCount: 3,
includeDescendants: true,
costCents: 600,
inputTokens: 60,
cachedInputTokens: 6,
outputTokens: 12,
runCount: 0,
runtimeMs: 0,
});
});
it("aggregates run wall-clock duration across the recursive issue tree", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const grandchildIssueId = randomUUID();
const harnessIssueId = randomUUID();
const siblingIssueId = 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: "Run Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "in_progress",
priority: "medium",
issueNumber: 1,
identifier: "TST-1",
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Child",
status: "in_progress",
priority: "medium",
issueNumber: 2,
identifier: "TST-2",
},
{
id: grandchildIssueId,
companyId,
parentId: childIssueId,
title: "Grandchild",
status: "done",
priority: "medium",
issueNumber: 3,
identifier: "TST-3",
},
{
id: siblingIssueId,
companyId,
title: "Sibling",
status: "done",
priority: "medium",
issueNumber: 4,
identifier: "TST-4",
},
{
id: harnessIssueId,
companyId,
parentId: rootIssueId,
title: "Harness child",
status: "done",
priority: "medium",
workMode: "skill_test",
harnessKind: "skill_test",
issueNumber: 5,
identifier: "TST-5",
},
]);
const linkedViaContextRunId = randomUUID();
const linkedViaActivityRunId = randomUUID();
const grandchildRunId = randomUUID();
const harnessRunId = randomUUID();
const siblingRunId = randomUUID();
const livePartialRunId = randomUUID();
await db.insert(heartbeatRuns).values([
// 60s run linked to root via contextSnapshot.issueId
{
id: linkedViaContextRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:00:00.000Z"),
finishedAt: new Date("2026-04-10T00:01:00.000Z"),
contextSnapshot: { issueId: rootIssueId },
},
// 120s run linked to child via activity_log
{
id: linkedViaActivityRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:05:00.000Z"),
finishedAt: new Date("2026-04-10T00:07:00.000Z"),
},
// 30s run linked to grandchild
{
id: grandchildRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:10:00.000Z"),
finishedAt: new Date("2026-04-10T00:10:30.000Z"),
contextSnapshot: { issueId: grandchildIssueId },
},
// 45s harness run under root - should be excluded from visible issue tree rollups
{
id: harnessRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:15:00.000Z"),
finishedAt: new Date("2026-04-10T00:15:45.000Z"),
contextSnapshot: { issueId: harnessIssueId },
},
// sibling run NOT under root – should be excluded
{
id: siblingRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:20:00.000Z"),
finishedAt: new Date("2026-04-10T00:21:00.000Z"),
contextSnapshot: { issueId: siblingIssueId },
},
// Still-running run on child (no finishedAt) – should contribute (now - startedAt)
{
id: livePartialRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "running",
startedAt: new Date(Date.now() - 5_000),
contextSnapshot: { issueId: childIssueId },
},
]);
await db.insert(activityLog).values({
companyId,
runId: linkedViaActivityRunId,
actorType: "agent",
actorId: agentId,
agentId,
action: "issue.checked_out",
entityType: "issue",
entityId: childIssueId,
details: {},
});
const summary = await costs.issueTreeSummary(companyId, rootIssueId);
expect(summary.issueCount).toBe(3);
// 3 finished runs in tree (root, child via activity, grandchild) + 1 live run
expect(summary.runCount).toBe(4);
// 60s + 120s + 30s = 210s = 210_000ms from finished runs.
// Live run adds ~5_000ms; allow some slack so the assertion isn't flaky.
expect(summary.runtimeMs).toBeGreaterThanOrEqual(210_000 + 4_000);
expect(summary.runtimeMs).toBeLessThan(210_000 + 60_000);
// excludeRoot drops the root issue's own runs (the 60s contextSnapshot run)
// while keeping the child + grandchild runs and any live child run.
const descendantsOnly = await costs.issueTreeSummary(companyId, rootIssueId, {
excludeRoot: true,
});
expect(descendantsOnly.issueCount).toBe(2);
expect(descendantsOnly.runCount).toBe(3);
// 120s + 30s = 150s + ~5s live run
expect(descendantsOnly.runtimeMs).toBeGreaterThanOrEqual(150_000 + 4_000);
expect(descendantsOnly.runtimeMs).toBeLessThan(150_000 + 60_000);
});
it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(financeEvents).values([
{
companyId,
biller: "openai",
eventKind: "invoice",
amountCents: 2_000_000_000,
currency: "USD",
direction: "debit",
estimated: false,
occurredAt: new Date("2026-04-10T00:00:00.000Z"),
},
{
companyId,
biller: "openai",
eventKind: "invoice",
amountCents: 2_000_000_000,
currency: "USD",
direction: "debit",
estimated: true,
occurredAt: new Date("2026-04-11T00:00:00.000Z"),
},
]);
const range = {
from: new Date("2026-04-01T00:00:00.000Z"),
to: new Date("2026-04-15T23:59:59.999Z"),
};
const summary = await finance.summary(companyId, range);
const [byKindRow] = await finance.byKind(companyId, range);
expect(summary.debitCents).toBe(4_000_000_000);
expect(summary.estimatedDebitCents).toBe(2_000_000_000);
expect(byKindRow?.debitCents).toBe(4_000_000_000);
expect(byKindRow?.netCents).toBe(4_000_000_000);
});
});