fix: make accounting durable and synchronize cost reporting

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-10-06 21:32:48 -07:00
1 parent a6306ba606
commit 2fe2dd06bd
99 files changed
+59636 -2587

No files matched your search

+15 -3
View File
@@ -1,5 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalService } from "../services/approvals.ts";
import { companies } from "@paperclipai/db";
vi.mock("../services/budgets.js", () => ({
budgetService: () => ({ deliverPendingEnforcement: vi.fn(async () => {}) }),
budgetServiceInTransaction: () => ({ upsertPolicy: vi.fn(async () => {}) }),
}));
const mockAgentService = vi.hoisted(() => ({
activatePendingApproval: vi.fn(),
@@ -39,8 +45,11 @@ function createApproval(status: string): ApprovalRecord {
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
const pendingSelectResults = [...selectResults];
const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []);
const from = vi.fn(() => ({ where: selectWhere }));
let inTransaction = false;
const selectWhere = vi.fn(async () => (inTransaction ? pendingSelectResults.shift() : pendingSelectResults[0]) ?? []);
const from = vi.fn((table) => table === companies
? { where: () => ({ for: async () => [{ id: "company-1" }] }) }
: { where: selectWhere });
const select = vi.fn(() => ({ from }));
const returning = vi.fn(async () => updateResults);
@@ -49,7 +58,10 @@ function createDbStub(selectResults: ApprovalRecord[][], updateResults: Approval
const update = vi.fn(() => ({ set }));
return {
db: { select, update, transaction: vi.fn(async (callback: (db: unknown) => Promise<unknown>) => callback({ select, update })) },
db: { select, update, transaction: vi.fn(async (callback: (db: unknown) => Promise<unknown>) => {
inTransaction = true;
try { return await callback({ select, update }); } finally { inTransaction = false; }
}) },
selectWhere,
returning,
};
+22 -309
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
agents,
@@ -22,313 +23,8 @@ vi.mock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
type SelectResult = unknown[];
function createDbStub(selectResults: SelectResult[]) {
const pendingSelects = [...selectResults];
const selectWhere = vi.fn(() => {
const query = Promise.resolve(pendingSelects.shift() ?? []);
return Object.assign(query, { for: () => query });
});
const selectThen = vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pendingSelects.shift() ?? [])));
const selectOrderBy = vi.fn(async () => pendingSelects.shift() ?? []);
const selectFrom = vi.fn(() => ({
where: selectWhere,
then: selectThen,
orderBy: selectOrderBy,
}));
const select = vi.fn(() => ({
from: selectFrom,
}));
const insertValues = vi.fn();
const insertReturning = vi.fn(async () => pendingInserts.shift() ?? []);
const insert = vi.fn(() => ({
values: insertValues.mockImplementation(() => ({
returning: insertReturning,
})),
}));
const updateSet = vi.fn();
const updateWhere = vi.fn(async () => pendingUpdates.shift() ?? []);
const update = vi.fn(() => ({
set: updateSet.mockImplementation(() => ({
where: updateWhere,
})),
}));
const pendingInserts: unknown[][] = [];
const pendingUpdates: unknown[][] = [];
return {
db: {
select,
insert,
update,
transaction: async (callback: (db: unknown) => Promise<unknown>) => callback({ select, insert, update }),
},
queueInsert: (rows: unknown[]) => {
pendingInserts.push(rows);
},
queueUpdate: (rows: unknown[] = []) => {
pendingUpdates.push(rows);
},
selectWhere,
insertValues,
updateSet,
};
}
describe("budgetService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a hard-stop incident and pauses an agent when spend exceeds a budget", async () => {
const policy = {
id: "policy-1",
companyId: "company-1",
scopeType: "agent",
scopeId: "agent-1",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 100,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: false,
isActive: true,
};
const dbStub = createDbStub([
[policy],
[{ total: 150 }],
[],
[{
companyId: "company-1",
name: "Budget Agent",
status: "running",
pauseReason: null,
}],
[{ id: "agent-1", companyId: "company-1", status: "running" }],
]);
dbStub.queueInsert([{
id: "approval-1",
companyId: "company-1",
status: "pending",
}]);
dbStub.queueInsert([{
id: "incident-1",
companyId: "company-1",
policyId: "policy-1",
approvalId: "approval-1",
}]);
dbStub.queueUpdate([]);
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
const service = budgetService(dbStub.db as any, { cancelWorkForScope });
await service.evaluateCostEvent({
companyId: "company-1",
agentId: "agent-1",
projectId: null,
} as any);
expect(dbStub.insertValues).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-1",
type: "budget_override_required",
status: "pending",
}),
);
expect(dbStub.insertValues).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-1",
policyId: "policy-1",
thresholdType: "hard",
amountLimit: 100,
amountObserved: 150,
approvalId: "approval-1",
}),
);
expect(dbStub.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
status: "paused",
pauseReason: "budget",
pausedAt: expect.any(Date),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "budget.hard_threshold_crossed",
entityId: "incident-1",
}),
);
expect(cancelWorkForScope).toHaveBeenCalledWith({
companyId: "company-1",
scopeType: "agent",
scopeId: "agent-1",
});
});
it("blocks new work when an agent hard-stop remains exceeded even if the agent is not paused yet", async () => {
const agentPolicy = {
id: "policy-agent-1",
companyId: "company-1",
scopeType: "agent",
scopeId: "agent-1",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 100,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: true,
};
const dbStub = createDbStub([
[{
status: "running",
pauseReason: null,
companyId: "company-1",
name: "Budget Agent",
}],
[{
status: "active",
name: "Paperclip",
}],
[],
[agentPolicy],
[{ total: 120 }],
]);
const service = budgetService(dbStub.db as any);
const block = await service.getInvocationBlock("company-1", "agent-1");
expect(block).toEqual({
scopeType: "agent",
scopeId: "agent-1",
scopeName: "Budget Agent",
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
});
});
it("surfaces a budget-owned company pause distinctly from a manual pause", async () => {
const dbStub = createDbStub([
[{
status: "idle",
pauseReason: null,
companyId: "company-1",
name: "Budget Agent",
}],
[{
status: "paused",
pauseReason: "budget",
name: "Paperclip",
}],
]);
const service = budgetService(dbStub.db as any);
const block = await service.getInvocationBlock("company-1", "agent-1");
expect(block).toEqual({
scopeType: "company",
scopeId: "company-1",
scopeName: "Paperclip",
reason: "Company is paused because its budget hard-stop was reached.",
});
});
it("uses live observed spend when raising a budget incident", async () => {
const dbStub = createDbStub([
[{
id: "incident-1",
companyId: "company-1",
policyId: "policy-1",
amountObserved: 120,
approvalId: "approval-1",
}],
[{
id: "policy-1",
companyId: "company-1",
scopeType: "company",
scopeId: "company-1",
metric: "billed_cents",
windowKind: "calendar_month_utc",
}],
[{ total: 150 }],
]);
const service = budgetService(dbStub.db as any);
await expect(
service.resolveIncident(
"company-1",
"incident-1",
{ action: "raise_budget_and_resume", amount: 140 },
"board-user",
),
).rejects.toThrow("New budget must exceed current observed spend");
});
it("syncs company monthly budget when raising and resuming a company incident", async () => {
const now = new Date();
const dbStub = createDbStub([
[{
id: "incident-1",
companyId: "company-1",
policyId: "policy-1",
scopeType: "company",
scopeId: "company-1",
metric: "billed_cents",
windowKind: "calendar_month_utc",
windowStart: now,
windowEnd: now,
thresholdType: "hard",
amountLimit: 100,
amountObserved: 120,
status: "open",
approvalId: "approval-1",
resolvedAt: null,
createdAt: now,
updatedAt: now,
}],
[{
id: "policy-1",
companyId: "company-1",
scopeType: "company",
scopeId: "company-1",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 100,
}],
[{ total: 120 }],
[{ id: "approval-1", status: "approved" }],
[{
companyId: "company-1",
name: "Paperclip",
status: "paused",
pauseReason: "budget",
pausedAt: now,
}],
]);
const service = budgetService(dbStub.db as any);
await service.resolveIncident(
"company-1",
"incident-1",
{ action: "raise_budget_and_resume", amount: 175 },
"board-user",
);
expect(dbStub.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
budgetMonthlyCents: 175,
updatedAt: expect.any(Date),
}),
);
});
});
// Budget behavior is exercised against PostgreSQL below and in
// cost-accounting-reliability.test.ts, including transaction and retry paths.
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -493,7 +189,7 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
expect(agentAfterHardStop).toMatchObject({ status: "paused", pauseReason: "budget" });
expect(agentAfterHardStop?.pausedAt).toBeInstanceOf(Date);
expect(cancelWorkForScope).toHaveBeenCalledTimes(2);
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId });
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId, createdBefore: expect.any(Date), enforcement: { policyId: expect.any(String), version: expect.any(Number) } });
const block = await service.getInvocationBlock(companyId, agentId);
expect(block).toEqual({
@@ -538,6 +234,23 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
}
});
it("distinguishes a company budget pause from a manual company pause", async () => {
const { companyId, agentId } = await createBudgetFixture();
const service = budgetService(db);
await db.update(companies).set({ status: "paused", pauseReason: "budget" }).where(eq(companies.id, companyId));
expect(await service.getInvocationBlock(companyId, agentId)).toMatchObject({
scopeType: "company",
scopeId: companyId,
reason: "Budget pause requires a policy or an explicit operator resume.",
});
await db.update(companies).set({ pauseReason: null }).where(eq(companies.id, companyId));
expect(await service.getInvocationBlock(companyId, agentId)).toMatchObject({
scopeType: "company",
scopeId: companyId,
reason: "Company is paused and cannot start new work.",
});
});
it("hard-stops project work until a valid budget raise resumes it and overview reconciles ledger spend", async () => {
const { companyId, agentId, projectId } = await createBudgetFixture();
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
@@ -578,7 +291,7 @@ describeEmbeddedPostgres("budgetService release gate enforcement", () => {
.from(projects);
expect(projectAfterHardStop?.pauseReason).toBe("budget");
expect(projectAfterHardStop?.pausedAt).toBeInstanceOf(Date);
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId });
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId, createdBefore: expect.any(Date), enforcement: { policyId: expect.any(String), version: expect.any(Number) } });
const overviewWhileBlocked = await service.overview(companyId);
expect(overviewWhileBlocked.pausedProjectCount).toBe(1);
+29 -18
View File
@@ -221,6 +221,21 @@ describe("cost routes", () => {
});
});
it("requires an explicit all-time request and rejects conflicting ranges", () => {
const { parseCostDateRange } = loadCostParsers();
expect(parseCostDateRange({})).toBeUndefined();
expect(parseCostDateRange({ period: "all" })).toEqual({ allTime: true });
for (const input of [
{ from: "2026-09-02", to: "2026-09-01" },
{ from: ["2026-09-01"] },
{ from: "2026-02-30" },
{ from: "" },
{ from: "09/01/2026" },
{ period: "all", from: "2026-09-01" },
{ period: "unknown" },
]) expect(() => parseCostDateRange(input)).toThrow();
});
it("returns 400 for an invalid 'from' date string", async () => {
const { parseCostDateRange } = loadCostParsers();
expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i);
@@ -270,7 +285,7 @@ describe("cost routes", () => {
it("returns 400 for invalid finance event list limits", async () => {
const { parseCostLimit } = loadCostParsers();
expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i);
for (const limit of ["0", "25garbage", "1.5", ["25"], "501"]) expect(() => parseCostLimit({ limit })).toThrow(/invalid 'limit'/i);
});
it("accepts valid finance event list limits", async () => {
@@ -352,7 +367,8 @@ describe("cost routes", () => {
});
it("allows authorized board users to update an agent budget and budget policy", async () => {
mockAgentService.update.mockResolvedValueOnce({
mockAgentService.getById.mockResolvedValueOnce({ id: "agent-1", companyId: "company-1", name: "Budget Agent", budgetMonthlyCents: 100, spentMonthlyCents: 0 });
mockAgentService.getById.mockResolvedValueOnce({
id: "agent-1",
companyId: "company-1",
name: "Budget Agent",
@@ -373,30 +389,22 @@ describe("cost routes", () => {
.send({ budgetMonthlyCents: 2500 });
expect(res.status).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith("agent-1", { budgetMonthlyCents: 2500 });
expect(res.body.budgetMonthlyCents).toBe(2500);
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockBudgetService.upsertPolicy).toHaveBeenCalledTimes(1);
expect(mockBudgetService.upsertPolicy).toHaveBeenCalledWith(
"company-1",
{
scopeType: "agent",
scopeId: "agent-1",
amount: 2500,
isActive: true,
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 },
}),
);
// Policy mutation owns the atomic entity update and activity log.
expect(mockLogActivity).not.toHaveBeenCalled();
});
});
@@ -543,7 +551,9 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
const [byAgentModelRow] = await costs.byAgentModel(companyId, range);
expect(byAgentRow?.costCents).toBe(4_000_000_000);
expect(byAgentRow?.inputTokens).toBe(4_000_000_000);
// Historical OpenAI input includes these ten cached tokens.
expect(byAgentRow?.inputTokens).toBe(3_999_999_990);
expect(byAgentRow!.inputTokens + byAgentRow!.cachedInputTokens).toBe(4_000_000_000);
expect(byProjectRow?.costCents).toBe(4_000_000_000);
expect(byAgentModelRow?.costCents).toBe(4_000_000_000);
});
@@ -692,7 +702,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
issueCount: 3,
includeDescendants: true,
costCents: 600,
inputTokens: 60,
costCentsExact: "600.0000000",
inputTokens: 54,
cachedInputTokens: 6,
outputTokens: 12,
runCount: 0,
+2 -1
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import { costEvents, agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -26,6 +26,7 @@ describeEmbeddedPostgres("heartbeat list", () => {
}, 20_000);
afterEach(async () => {
await db.delete(costEvents);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
@@ -33,7 +33,9 @@ import {
agentWakeupRequests,
approvals,
authUsers,
budgetIncidents,
budgetPolicies,
budgetReservations,
chatActions,
chatConversations,
chatDeliveries,
@@ -601,6 +603,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(issueTreeHolds);
await db.delete(agentTaskSessions);
await db.delete(issueApprovals);
await db.delete(budgetIncidents);
await db.delete(approvals);
await db.update(issues).set({ lastStatusDecisionId: null });
await db.delete(statusDecisionEffects);
@@ -7798,6 +7801,36 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
},
);
it.each([{}, { command: "/nonexistent-paperclip-bootstrap-command" }])("releases holds for real process bootstrap failures: %j", async (config) => {
const { execute } = await import("../adapters/process/execute.js");
mockAdapterExecute.mockImplementationOnce(execute);
const { runId, agentId } = await seedRunFixture({ runtimeMode: "legacy", agentStatus: "idle", runStatus: "queued", includeIssue: false });
await db.update(agents).set({ adapterType: "process", adapterConfig: config }).where(eq(agents.id, agentId));
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await heartbeat.drainActiveRunExecutions();
expect(await heartbeat.getRun(runId)).toMatchObject({ status: "failed", costAccountingPending: false, costAccountedAt: expect.any(Date) });
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.runId, runId)))[0]?.state).toBe("released");
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, runId))).toEqual([]);
});
it("releases the dispatch reservation after a proven pre-provider adapter failure", async () => {
mockAdapterExecute.mockResolvedValueOnce({
exitCode: 1, signal: null, timedOut: false,
errorMessage: "Provider startup failed", provider: "test", model: "test-model",
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
});
const { runId } = await seedRunFixture({ runtimeMode: "legacy", agentStatus: "idle", runStatus: "queued", includeIssue: false });
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await heartbeat.drainActiveRunExecutions();
const run = await heartbeat.getRun(runId);
expect(run).toMatchObject({ status: "failed", costAccountingPending: false });
expect(run?.costAccountedAt).not.toBeNull();
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.runId, runId)))[0]).toMatchObject({ state: "released" });
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, runId))).toEqual([]);
});
it.each(["single Stop", "agent pause"] as const)(
"fences adapter registration while an earlier no-owner %s waits to commit",
async (operation) => {
@@ -7922,6 +7955,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
releaseAdapter();
await heartbeat.drainActiveRunExecutions();
const settledRun = await heartbeat.getRun(runId);
expect(settledRun?.costAccountingPending).toBe(false);
expect(settledRun?.costAccountedAt).not.toBeNull();
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.runId, runId)))[0]).toMatchObject({ state: "released" });
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, runId))).toEqual([]);
expect({
status: settledRun?.status,
errorCode: settledRun?.errorCode,
@@ -11313,6 +11350,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await waitForRunToSettle(heartbeat, sourceRun.id, 5_000);
}
expect(sourceRun?.id).not.toBe(runId);
// Durable accounting is bookkeeping, not evidence that the agent acted
// on its plan. A receipt must not suppress plan-only recovery.
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, String(sourceRunId)))).toHaveLength(1);
expect(sourceRun?.livenessState).toBe("plan_only");
if (withLegacyReview) {
expect(await db.select().from(issues).where(eq(issues.id, legacyReviewId))).toEqual(legacyReviewBefore);
@@ -11,6 +11,7 @@ import {
agentInstructionHeads,
agents,
approvals,
budgetPolicies,
companies,
createDb,
pluginEntities,
@@ -107,6 +108,7 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
await db.delete(pluginManagedResources);
await db.delete(pluginCompanySettings);
await db.delete(approvals);
await db.delete(budgetPolicies);
await db.delete(principalPermissionGrants);
await db.delete(agents);
await db.delete(plugins);
@@ -499,6 +501,7 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(relinked.status).toBe("relinked");
expect(relinked.agentId).toBe(agentId);
expect(relinked.agent?.urlKey).toBe("renamed-wiki-agent");
const [binding] = await db.select().from(pluginEntities);
expect(binding?.data).toMatchObject({ agentId });
+4 -4
View File
@@ -166,10 +166,10 @@ function registerModuleMocks() {
}
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"),
]);
// Load the route after its mocks are installed. Concurrent importActual
// calls through the middleware barrel can cache unmocked dependencies.
const { errorHandler } = await import("../middleware/error-handler.js");
const { routineRoutes } = await import("../routes/routines.js");
const app = express();
app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));
app.use((req, _res, next) => {
@@ -57,6 +57,7 @@ const {
reason: null,
}));
const heartbeatServiceMock = {
reconcileCostAccounting: vi.fn(async () => ({ scanned: 0, accounted: 0 })),
resolveSchedulingSuppression: resolveHeartbeatSchedulingSuppressionMock,
recoverNativeRunsAfterRestart: vi.fn(async () => ({
restartKind: "hard",
@@ -559,11 +560,13 @@ describe("startServer feedback export wiring", () => {
}) as typeof setInterval);
try {
await startServer();
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(1);
expect(intervalCallback).not.toBeNull();
intervalCallback?.();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(2);
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(2);
expect(retiredDetector).not.toHaveBeenCalled();
} finally {
delete (runtime as Partial<typeof runtime>).reconcileProductivityReviews;
@@ -660,6 +663,7 @@ describe("startServer feedback export wiring", () => {
// reaped at startup and on the interval.
expect(heartbeatServiceFactoryMock).toHaveBeenCalledTimes(1);
expect(heartbeatServiceMock.sweepPendingCleanupLeases).toHaveBeenCalled();
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
expect(intervalCallback).not.toBeNull();
intervalCallback?.();
await Promise.resolve();
@@ -667,12 +671,33 @@ describe("startServer feedback export wiring", () => {
expect(externalObjectsServiceMock.refreshDueObjectsForActiveCompanies).toHaveBeenCalledTimes(1);
expect(routineServiceMock.tickScheduledTriggers).not.toHaveBeenCalled();
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(2);
expect(environmentCustomImagesServiceMock.cleanupExpiredSetupSessions).not.toHaveBeenCalled();
} finally {
setIntervalSpy.mockRestore();
}
});
it.each([false, true])("retries failed accounting recovery with heartbeat scheduling enabled: %s", async (enabled) => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
heartbeatServiceMock.reconcileCostAccounting.mockRejectedValueOnce(new Error("temporary ledger outage"));
let tick: (() => void) | undefined;
const timer = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void) => {
tick = callback;
return 1 as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval);
try {
expect((await startServer()).server).toBe(fakeServer);
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
expect(tick).toBeDefined();
tick?.();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(2);
} finally {
timer.mockRestore();
}
});
it("does not replay hot-restart adoption when the orphan reaper retries", async () => {
loadConfigMock.mockReturnValue(buildTestConfig({
heartbeatSchedulerEnabled: true,
+9 -4
View File
@@ -879,6 +879,7 @@ async function startServerWithDatabaseTeardown(
const heartbeat = config.heartbeatSchedulerEnabled
? heartbeatService(db as any, { pluginWorkerManager })
: null;
const accountingHeartbeat = heartbeat ?? heartbeatService(db as any, { pluginWorkerManager });
const decisionServiceOptions = {
wakeOriginAgent: createDecisionWakeOriginAgent(heartbeat?.wakeup ?? null),
};
@@ -1230,8 +1231,7 @@ async function startServerWithDatabaseTeardown(
// so a just-failed lease does not draw a retry on every tick. The startup
// sweep passes zero, so a restart retries a stranded orphan at once.
const ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS = 5 * 60 * 1000;
const environmentLeaseCleanupHeartbeat =
heartbeat ?? heartbeatService(db as any, { pluginWorkerManager });
const environmentLeaseCleanupHeartbeat = accountingHeartbeat;
const chatCompletionDeliveries = chatCompletionDeliveryService(db as any, environmentLeaseCleanupHeartbeat);
// Activity publication happens after the status transaction commits. This is
// a best-effort fast path; the durable outbox and sweeps remain authoritative.
@@ -1449,6 +1449,7 @@ async function startServerWithDatabaseTeardown(
},
"worktree run-execution cutoff state",
);
await accountingHeartbeat.reconcileCostAccounting().catch((err) => logger.error({ err }, "Cost accounting recovery failed; pending receipts will retry"));
const heartbeatSchedulingSuppression = await heartbeat.resolveSchedulingSuppression();
// Reap orphaned runs before timer ticks start so wakeups cannot coalesce
@@ -1464,8 +1465,7 @@ async function startServerWithDatabaseTeardown(
// workers before cleanup or retry admission, including unmanaged installs.
await app.locals.bundledPluginsStartup;
try {
const nativeRecovery =
await heartbeat.recoverNativeRunsAfterRestart();
const nativeRecovery = await heartbeat.recoverNativeRunsAfterRestart();
if (nativeRecovery.dispositions.length > 0) {
logger.info(
{
@@ -1763,6 +1763,9 @@ async function startServerWithDatabaseTeardown(
}));
trackHeartbeatSchedulerWork(chatCompletionDeliveries.sweepPending().catch((err) => logger.error({ err }, "chat completion delivery failed")));
trackHeartbeatSchedulerWork(accountingHeartbeat.reconcileCostAccounting().catch((err) => logger.error({ err }, "Cost accounting recovery failed")));
trackHeartbeatSchedulerWork(connectionDeliveries.sweepPending().catch((err) => logger.error({ err }, "connection continuation delivery failed")));
trackHeartbeatSchedulerWork(app.locals.toolGateway.sweepActionReviews().catch((err: unknown) => logger.error({ err }, "tool review recovery failed")));
trackHeartbeatSchedulerWork(app.locals.toolGateway.cleanupExpiredSessions().catch((err: unknown) => logger.error({ err }, "gateway token cleanup failed")));
@@ -1839,8 +1842,10 @@ async function startServerWithDatabaseTeardown(
// is still required. A failed acquire can leak a paid provider sandbox, so
// this path retries the teardown at startup and on the interval, exactly as
// the enabled path does.
await accountingHeartbeat.reconcileCostAccounting().catch((err) => logger.error({ err }, "Cost accounting recovery failed; pending receipts will retry"));
await runEnvironmentLeaseCleanupSweep(0);
startHeartbeatSchedulerInterval(() => {
trackHeartbeatSchedulerWork(accountingHeartbeat.reconcileCostAccounting().catch((err) => logger.error({ err }, "Cost accounting recovery failed")));
scheduleExternalObjectRefreshSweep(new Date());
scheduleEnvironmentLeaseCleanupSweep();
scheduleGitHubConnectionEventPoll();
@@ -15,9 +15,11 @@ import {
issues,
} from "@paperclipai/db";
import { ISSUE_DISPOSITION_REPAIR_RETRY_REASON } from "@paperclipai/shared";
import { withAccountingTransaction } from "../../../services/accounting-transaction.js";
import type { ActivityPublication } from "../../../services/activity-log.js";
import { parseObject } from "../../../adapters/utils.js";
import { evaluateAgentInvokabilityFromDb } from "../../../services/agent-invokability.js";
import { budgetService } from "../../../services/budgets.js";
import { budgetService, budgetServiceInTransaction } from "../../../services/budgets.js";
import { isCompletedOnboardingHandoffWake } from "../../../services/chat-completion-delivery.js";
import { isHeartbeatWakeOnDemandEnabled } from "../../../services/heartbeat-policy.js";
import { collectDispositionRepairSourceState } from "../../../services/recovery/disposition-repair.js";
@@ -190,7 +192,7 @@ export function createPostgresRunDispatchAdapter(
async function withIssueThenRunLocks<T>(
input: { runId: string; companyId: string },
onMissing: () => T,
operation: (tx: Db, run: HeartbeatRun) => Promise<T>,
operation: (tx: Db, run: HeartbeatRun, publications: ActivityPublication[]) => Promise<T>,
): Promise<T> {
// Queue editing and claiming already use issue -> wake -> run. Read the
// immutable issue reference without a lock first, then acquire issue ->
@@ -206,8 +208,9 @@ export function createPostgresRunDispatchAdapter(
const hintedIssueId = readNonEmptyString(parseObject(hint.contextSnapshot).issueId);
const result: { kind: "missing" | "retry" } | { kind: "value"; value: T } =
await db.transaction(async (tx) => {
const typedTx = tx as unknown as Db;
await withAccountingTransaction(db, input.companyId, async (typedTx, publications) => {
// Budget gates mutate policy state. Acquire company before issue/run,
// matching receipt writers (including their attribution FK locks).
if (hintedIssueId) {
await typedTx
.select({ id: issues.id })
@@ -231,7 +234,7 @@ export function createPostgresRunDispatchAdapter(
const lockedIssueId = readNonEmptyString(parseObject(run.contextSnapshot).issueId);
if (lockedIssueId !== hintedIssueId) return { kind: "retry" as const };
return { kind: "value" as const, value: await operation(typedTx, run) };
return { kind: "value" as const, value: await operation(typedTx, run, publications) };
});
if (result.kind === "missing") return onMissing();
@@ -247,12 +250,13 @@ export function createPostgresRunDispatchAdapter(
input: LoadGateFactsInput,
now: Date,
tx?: unknown,
publications?: ActivityPublication[],
): Promise<LoadGateFactsResult> {
// Semantic adapter operations pass their transaction here so the fact
// read and the state transition share one unit of work. This helper is
// deliberately not exposed through the module's public API.
const dbOrTx = (tx as Db | undefined) ?? db;
const budgetsForRead = tx ? budgetService(dbOrTx) : budgets;
const budgetsForRead = tx ? budgetServiceInTransaction(dbOrTx, publications) : budgets;
const treeControlForRead = tx ? issueTreeControlService(dbOrTx) : treeControlSvc;
const issuesSvcForRead = tx ? issueService(dbOrTx) : issuesSvc;
@@ -747,7 +751,7 @@ export function createPostgresRunDispatchAdapter(
): Promise<PromoteScheduledRetryOutcome> {
const now = input.now;
const promoteLockedRun = async (tx: Db, run: HeartbeatRun) => {
const promoteLockedRun = async (tx: Db, run: HeartbeatRun, publications: ActivityPublication[]) => {
if (
run.status !== "scheduled_retry" ||
!run.scheduledRetryAt ||
@@ -768,6 +772,7 @@ export function createPostgresRunDispatchAdapter(
},
now,
tx,
publications,
);
if (!factsResult.agentFound) {
@@ -1010,7 +1015,7 @@ export function createPostgresRunDispatchAdapter(
input: DispatchResolvedInteractionInput<T>,
): Promise<DispatchResolvedInteractionOutcome<T>> {
const dispatchLockedRun = async (tx: Db, run: HeartbeatRun) => {
if (run.status !== input.expectedStatus) {
if (run.status !== input.expectedStatus || run.resultJson?.cancellation || run.resultJson?.startupCancellation) {
return { dispatched: false as const, cancellation: { outcome: "lost_race" as const } };
}
const { issueId, decision: initialDecision } = await decideCurrentRunStaleness(
+1 -1
View File
@@ -546,7 +546,7 @@ export function agentRoutes(
const KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET: ReadonlySet<string> = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS);
const router = Router();
const svc = agentService(db);
const svc = agentService(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
const access = accessService(db);
const approvalsSvc = approvalService(db);
const budgets = budgetService(db);
+4 -1
View File
@@ -55,6 +55,7 @@ import {
accessService,
agentService,
budgetService,
heartbeatService,
buildExportFidelityReport,
collectExportFidelityCounts,
companyArtifactsService,
@@ -278,7 +279,9 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan
const router = Router();
const importTransferSpoolRoot =
options?.importTransferSpoolRoot ?? resolveDefaultImportTransferSpoolRoot();
const svc = companyService(db);
const svc = companyService(db, {
cancelWorkForScope: (scope) => heartbeatService(db).cancelBudgetScopeWork(scope),
});
const agents = agentService(db);
const portability = companyPortabilityService(db, storage);
const access = accessService(db);
+82 -75
View File
@@ -1,4 +1,10 @@
import { importProviderDailyCosts } from "../services/provider-billing-import.js";
import { importProviderCostsSchema } from "@paperclipai/shared";
import { accountingIntegrityService } from "../services/accounting-integrity.js";
import { billingReconciliationService } from "../services/billing-reconciliation.js";
import { adjustCostSchema, importBillingInvoiceSchema, repairAccountingSchema, retryAccountingSchema } from "@paperclipai/shared";
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import {
createCostEventSchema,
@@ -18,28 +24,37 @@ import {
issueService,
heartbeatService,
accessService,
logActivity,
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
import { fetchCompanyQuotaWindows } from "../services/quota-windows.js";
import { badRequest } from "../errors.js";
import { badRequest, forbidden } from "../errors.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
const reportDateSchema = z.union([z.iso.datetime({ offset: true }), z.iso.date()]);
export function parseCostDateRange(query: Record<string, unknown>) {
if (query.period !== undefined && query.period !== "all" && query.period !== "month") throw badRequest("invalid 'period'");
if (query.period === "all") {
if (query.from !== undefined || query.to !== undefined) throw badRequest("all-time period cannot have date bounds");
return { allTime: true };
}
if (query.from !== undefined && !reportDateSchema.safeParse(query.from).success) throw badRequest("invalid 'from' date");
if (query.to !== undefined && !reportDateSchema.safeParse(query.to).success) throw badRequest("invalid 'to' date");
const fromRaw = query.from as string | undefined;
const toRaw = query.to as string | undefined;
const from = fromRaw ? new Date(fromRaw) : undefined;
const to = toRaw ? new Date(toRaw) : undefined;
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
if (from && to && from > to) throw badRequest("from must not be after to");
return (from || to) ? { from, to } : undefined;
}
export function parseCostLimit(query: Record<string, unknown>) {
const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit;
const raw = query.limit;
if (raw == null || raw === "") return 100;
const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
const limit = typeof raw === "number" || typeof raw === "string" ? Number(raw) : Number.NaN;
if (!Number.isInteger(limit) || limit <= 0 || limit > 500) {
throw badRequest("invalid 'limit' value");
}
return limit;
@@ -120,22 +135,11 @@ export function costRoutes(
return;
}
const actor = getActorInfo(req);
const event = await costs.createEvent(companyId, {
...req.body,
occurredAt: new Date(req.body.occurredAt),
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "cost.reported",
entityType: "cost_event",
entityId: event.id,
details: { costCents: event.costCents, model: event.model },
});
}, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId });
res.status(201).json(event);
});
@@ -145,27 +149,11 @@ export function costRoutes(
assertCompanyAccess(req, companyId);
assertBoard(req);
const actor = getActorInfo(req);
const event = await finance.createEvent(companyId, {
...req.body,
occurredAt: new Date(req.body.occurredAt),
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "finance_event.reported",
entityType: "finance_event",
entityId: event.id,
details: {
amountCents: event.amountCents,
biller: event.biller,
eventKind: event.eventKind,
direction: event.direction,
},
});
}, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId });
res.status(201).json(event);
});
@@ -189,6 +177,14 @@ export function costRoutes(
res.json(summary);
});
router.get("/companies/:companyId/costs/by-user", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
res.json(await costs.byUser(companyId, range));
});
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -331,34 +327,19 @@ export function costRoutes(
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const company = await companies.update(companyId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "company.budget_updated",
entityType: "company",
entityId: companyId,
details: { budgetMonthlyCents: req.body.budgetMonthlyCents },
});
await budgets.upsertPolicy(
companyId,
{
scopeType: "company",
scopeId: companyId,
amount: req.body.budgetMonthlyCents,
isActive: req.body.budgetMonthlyCents > 0,
windowKind: "calendar_month_utc",
},
req.actor.userId ?? "board",
);
res.json(company);
res.json(await companies.getById(companyId));
});
router.patch("/agents/:agentId/budgets", validate(updateBudgetSchema), async (req, res) => {
@@ -368,37 +349,63 @@ export function costRoutes(
assertBoard(req);
const updated = await agents.update(agentId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
if (!updated) {
res.status(404).json({ error: "Agent not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: updated.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "agent.budget_updated",
entityType: "agent",
entityId: updated.id,
details: { budgetMonthlyCents: updated.budgetMonthlyCents },
});
await budgets.upsertPolicy(
updated.companyId,
agent.companyId,
{
scopeType: "agent",
scopeId: updated.id,
amount: updated.budgetMonthlyCents,
scopeId: agent.id,
amount: req.body.budgetMonthlyCents,
isActive: req.body.budgetMonthlyCents > 0,
windowKind: "calendar_month_utc",
},
req.actor.type === "board" ? req.actor.userId ?? "board" : null,
);
res.json(updated);
res.json(await agents.getById(agentId));
});
const integrity = accountingIntegrityService(db, budgetHooks);
const billing = billingReconciliationService(db, budgetHooks);
// Operational accounting evidence and repairs are board-only. These payloads
// can contain company-wide billing identifiers even for an agent's own run.
router.use("/companies/:companyId/accounting", (req, _res, next) => {
assertCompanyAccess(req, req.params.companyId as string); assertBoard(req); next();
});
router.post("/companies/:companyId/accounting/provider-costs/import", (req, _res, next) => {
// Unlike ledger inspection, this operation discloses a stored credential
// to a provider. Ordinary company membership does not authorize that use.
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const membership = req.actor.memberships?.find((item) => item.companyId === companyId);
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin
&& !(membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole)))) {
throw forbidden("Company admin access required to import provider billing.");
}
next();
}, validate(importProviderCostsSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const result = await importProviderDailyCosts(db, companyId, req.body, getActorInfo(req).actorId);
res.json(result);
});
router.get("/companies/:companyId/accounting/health", async (req, res) => res.json(await integrity.health(req.params.companyId as string)));
router.get("/companies/:companyId/accounting/inspect", async (req, res) => res.json(await integrity.inspect(req.params.companyId as string)));
router.post("/companies/:companyId/accounting/repair", validate(repairAccountingSchema), async (req, res) =>
res.json(await integrity.repair(req.params.companyId as string, req.body.fingerprint, req.body.reason, getActorInfo(req).actorId)));
router.post("/companies/:companyId/accounting/retry", validate(retryAccountingSchema), async (req, res) =>
res.json(await integrity.retry(req.params.companyId as string, req.body.runId, getActorInfo(req).actorId)));
router.get("/companies/:companyId/accounting/invoices", async (req, res) => res.json(await billing.list(req.params.companyId as string)));
router.post("/companies/:companyId/accounting/invoices", validate(importBillingInvoiceSchema), async (req, res) =>
res.status(201).json(await billing.importInvoice(req.params.companyId as string, req.body, getActorInfo(req).actorId)));
router.get("/companies/:companyId/accounting/invoices/:invoiceId", async (req, res) =>
res.json(await billing.reconcile(req.params.companyId as string, req.params.invoiceId as string)));
router.get("/companies/:companyId/accounting/events/:eventId/adjustments", async (req, res) =>
res.json(await billing.adjustments(req.params.companyId as string, req.params.eventId as string)));
router.post("/companies/:companyId/accounting/events/:eventId/adjustments", validate(adjustCostSchema), async (req, res) =>
res.status(201).json(await billing.adjust(req.params.companyId as string, req.params.eventId as string, req.body, getActorInfo(req).actorId)));
return router;
}
+56 -3
View File
@@ -127,6 +127,11 @@ import {
updateBudgetSchema,
upsertBudgetPolicySchema,
resolveBudgetIncidentSchema,
repairAccountingSchema,
retryAccountingSchema,
importBillingInvoiceSchema,
importProviderCostsSchema,
adjustCostSchema,
// Sidebar
upsertSidebarOrderPreferenceSchema,
// Announcements
@@ -1327,6 +1332,7 @@ const PUBLIC_OPERATIONS = new Set([
]);
const BOARD_ONLY_PREFIXES = [
"/api/companies/{companyId}/accounting/",
"/api/announcements/",
"/api/auth/",
"/api/admin/",
@@ -5324,9 +5330,16 @@ registry.registerPath({
// ─── Costs ───────────────────────────────────────────────────────────────────
const costReportQuerySchema = z.object({
period: z.enum(["month", "all"]).optional().describe("Defaults to the current UTC month. All-time cannot be combined with date bounds."),
from: z.union([z.iso.datetime({ offset: true }), z.iso.date()]).optional().describe("Inclusive start bound."),
to: z.union([z.iso.datetime({ offset: true }), z.iso.date()]).optional().describe("Inclusive end bound; must not precede from."),
});
const costSummaryPaths = [
"summary",
"by-agent",
"by-user",
"by-agent-model",
"by-provider",
"by-biller",
@@ -5345,7 +5358,12 @@ for (const segment of costSummaryPaths) {
path: `/api/companies/{companyId}/costs/${segment}`,
tags: ["costs"],
summary: `Cost report: ${segment}`,
request: { params: z.object({ companyId: z.string() }) },
request: {
params: z.object({ companyId: z.string() }),
...(!["window-spend", "quota-windows"].includes(segment) ? {
query: segment === "finance-events" ? costReportQuerySchema.extend({ limit: z.coerce.number().int().min(1).max(500).optional() }) : costReportQuerySchema,
} : {}),
},
responses: { 200: r.ok(), 401: r.unauthorized },
});
}
@@ -5355,11 +5373,12 @@ registry.registerPath({
path: "/api/companies/{companyId}/cost-events",
tags: ["costs"],
summary: "Record a cost event",
description: "Amounts are USD cents, including fractional cents. Decimal strings preserve exact nanodollar precision. Reuse the idempotency key and original receipt on retries; conflicting reuse returns 409.",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(createCostEventSchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 409: r.conflict },
});
registry.registerPath({
@@ -5367,13 +5386,47 @@ registry.registerPath({
path: "/api/companies/{companyId}/finance-events",
tags: ["costs"],
summary: "Record a finance event",
description: "Amounts accept exact decimal cents in the recorded currency. Credits use a nonnegative amount with direction credit. No currency conversion is inferred. Receipt-key conflicts return 409.",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(createFinanceEventSchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 409: r.conflict },
});
for (const [suffix, summary] of [
["health", "Inspect pending receipts, unpriced charges, cancellation backlog, and held reservations"],
["inspect", "Independently compare ledger evidence and stored totals"],
["invoices", "List imported provider invoices"],
["invoices/{invoiceId}", "Review exact invoice matches and unresolved differences"],
["events/{eventId}/adjustments", "Read append-only correction history and prior pricing evidence"],
] as const) {
registry.registerPath({
method: "get", path: `/api/companies/{companyId}/accounting/${suffix}`, tags: ["costs"], summary,
description: "Company-scoped board access is required. Board viewers may inspect; agents cannot access operator accounting evidence.",
request: { params: z.object({ companyId: z.string(),
...(suffix.includes("invoiceId") ? { invoiceId: z.string() } : {}),
...(suffix.includes("eventId") ? { eventId: z.string() } : {}),
}) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
}
for (const operation of [
{ suffix: "repair", schema: repairAccountingSchema, status: 200, summary: "Repair reviewed ledger projections", description: "Requires an audit reason and the current inspection fingerprint. A stale inspection returns 409. Missing evidence is never invented." },
{ suffix: "retry", schema: retryAccountingSchema, status: 200, summary: "Retry accounting for a company-owned run", description: "Retries accounting only. Provider work is never re-executed; incomplete evidence remains pending." },
{ suffix: "provider-costs/import", schema: importProviderCostsSchema, status: 200, summary: "Import a scoped provider cost report", description: "Reads up to 31 completed UTC days from OpenAI or Anthropic with a company admin credential. Requires explicit provider account and project/workspace identifiers. All pages validate before writing. Reimports append only the difference; run costs are not repriced. Report totals remain separate from invoices." },
{ suffix: "invoices", schema: importBillingInvoiceSchema, status: 201, summary: "Import normalized invoice evidence", description: "Idempotent by company, biller, and external invoice ID. Conflicting reuse returns 409. Import atomically creates finance timeline events and does not reprice run costs." },
{ suffix: "events/{eventId}/adjustments", schema: adjustCostSchema, status: 201, summary: "Apply a reviewed cost correction", description: "Requires the expected exact cents, an idempotency key, reason, and pricing evidence. Original amounts and pricing revisions remain in history. Stale reviews return 409; ambiguous or contradictory invoice evidence returns 422." },
] as const) {
registry.registerPath({
method: "post", path: `/api/companies/{companyId}/accounting/${operation.suffix}`, tags: ["costs"],
summary: operation.summary, description: `${operation.description} Company-scoped board write access is required; viewers and agents cannot mutate accounting.`,
request: { params: z.object({ companyId: z.string(), ...(operation.suffix.includes("eventId") ? { eventId: z.string() } : {}) }), body: jsonBody(operation.schema) },
responses: { [operation.status]: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
});
}
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/budgets/policies",
+121
View File
@@ -0,0 +1,121 @@
import { and, eq, sql } from "drizzle-orm";
import { agents, agentRuntimeState, companies, heartbeatRuns, type Db } from "@paperclipai/db";
import { normalizeCents, usdToCents, type AccountingFinding, type AccountingHealth, type AccountingInspection } from "@paperclipai/shared";
import { conflict, notFound } from "../errors.js";
import { receiptFingerprint } from "./receipt-fingerprint.js";
import { withAccountingReadSnapshot, withAccountingTransaction } from "./accounting-transaction.js";
import { logActivity } from "./activity-log.js";
import { accountRunCost } from "./run-cost-accounting.js";
import type { BudgetServiceHooks } from "./budgets.js";
/** Independent aggregation: deliberately does not reuse writer projections. */
async function inspect(tx: Db, companyId: string): Promise<AccountingInspection> {
const findings: AccountingFinding[] = [];
const monthly = await tx.execute<{ kind: "company_projection" | "agent_projection"; id: string; actual: string; expected: string }>(sql`
with month_events as (select agent_id, cost_cents from cost_events where company_id = ${companyId}
and occurred_at >= date_trunc('month', now() at time zone 'UTC') at time zone 'UTC'
and occurred_at < (date_trunc('month', now() at time zone 'UTC') + interval '1 month') at time zone 'UTC')
select 'company_projection' as kind, id, spent_monthly_cents::text as actual,
(select coalesce(sum(cost_cents),0)::text from month_events) as expected from companies where id = ${companyId}
union all select 'agent_projection', a.id, a.spent_monthly_cents::text,
coalesce((select sum(e.cost_cents) from month_events e where e.agent_id = a.id),0)::text from agents a where a.company_id = ${companyId}`);
for (const row of monthly) if (normalizeCents(row.actual) !== normalizeCents(row.expected)) findings.push({ kind: row.kind, entityId: row.id, repairable: true,
actual: { cents: normalizeCents(row.actual) }, expected: { cents: normalizeCents(row.expected) } });
const runtime = await tx.execute<{ id: string; baseline: string | null; actual: Record<string, string>; expected: Record<string, string> }>(sql`
with totals as (select r.agent_id, sum(e.cost_cents) as cents, sum(e.input_tokens) as input, sum(e.cached_input_tokens) as cached, sum(e.output_tokens) as output
from heartbeat_runs r join cost_events e on e.heartbeat_run_id = r.id and e.company_id = r.company_id
where r.company_id = ${companyId} and r.accounting_projection_version = 'v2' and r.cost_accounted_at is not null group by r.agent_id)
select a.agent_id as id, b.agent_id as baseline,
jsonb_build_object('cents', a.total_cost_cents::text, 'input', a.total_input_tokens::text, 'cached', a.total_cached_input_tokens::text, 'output', a.total_output_tokens::text) as actual,
jsonb_build_object('cents', (coalesce(b.cost_cents,0) + coalesce(t.cents,0))::text, 'input', (coalesce(b.input_tokens,0) + coalesce(t.input,0))::text,
'cached', (coalesce(b.cached_input_tokens,0) + coalesce(t.cached,0))::text, 'output', (coalesce(b.output_tokens,0) + coalesce(t.output,0))::text) as expected
from agent_runtime_state a left join accounting_runtime_baselines b on b.agent_id = a.agent_id and b.company_id = a.company_id
left join totals t on t.agent_id = a.agent_id where a.company_id = ${companyId} order by a.agent_id`);
for (const row of runtime) {
if (!row.baseline) { findings.push({ kind: "legacy_runtime", entityId: row.id, repairable: false, actual: row.actual, expected: {} }); continue; }
row.actual.cents = normalizeCents(row.actual.cents); row.expected.cents = normalizeCents(row.expected.cents);
if (Object.keys(row.expected).some(key => row.actual[key] !== row.expected[key])) findings.push({ kind: "runtime_projection", entityId: row.id, repairable: true, actual: row.actual, expected: row.expected });
}
const incomplete = await tx.execute<{ id: string; acknowledged: boolean; events: number }>(sql`
select r.id, r.cost_accounted_at is not null as acknowledged, count(e.id)::int as events from heartbeat_runs r
left join cost_events e on e.heartbeat_run_id = r.id and e.company_id = r.company_id and e.idempotency_key like ('heartbeat:' || r.id::text || ':%')
where r.company_id = ${companyId} and r.status in ('succeeded','failed','timed_out','cancelled','interrupted')
and (r.cost_accounting_pending or r.accounting_projection_version = 'v2')
and r.result_json->'executionRecovery'->>'providerWorkStarted' is distinct from 'false'
and r.usage_json->>'accountingProviderWorkStarted' is distinct from 'false'
group by r.id having r.cost_accounted_at is null or count(e.id) = 0 order by r.id`);
for (const row of incomplete) findings.push({ kind: row.acknowledged ? "missing_receipt" : "missing_acknowledgement", entityId: row.id, repairable: false,
actual: { acknowledged: String(row.acknowledged), events: String(row.events) }, expected: { acknowledged: "true", events: ">=1" } });
// Verify new run receipts independently of the projections. Corrections
// change effective valuation, while reported_cost_cents preserves the
// provider amount this acknowledgement originally consumed.
const receipts = await tx.execute<{ id: string; usage: Record<string, unknown>; actual: Record<string, string> }>(sql`
select r.id, r.usage_json as usage, jsonb_build_object('cents', sum(coalesce(e.reported_cost_cents,e.cost_cents))::text,
'input', sum(e.input_tokens)::text, 'cached', sum(e.cached_input_tokens)::text, 'output', sum(e.output_tokens)::text) as actual
from heartbeat_runs r join cost_events e on e.heartbeat_run_id = r.id and e.company_id = r.company_id and e.idempotency_key like ('heartbeat:' || r.id::text || ':%')
where r.company_id = ${companyId} and r.accounting_projection_version = 'v2' and r.cost_accounted_at is not null group by r.id order by r.id`);
for (const row of receipts) {
const usage = row.usage ?? {};
const cost = usage.cacheAdjustedCostUsd ?? usage.costUsdExact ?? usage.costUsd ?? 0;
let cents: string;
try { cents = usage.billingType === "subscription_included" ? "0.0000000" : usdToCents(String(cost)); }
catch { cents = "invalid receipt"; }
const expected = { cents, input: String(usage.inputTokens ?? 0), cached: String(usage.cachedInputTokens ?? 0), output: String(usage.outputTokens ?? 0) };
row.actual.cents = normalizeCents(row.actual.cents);
if (Object.entries(expected).some(([key,value]) => row.actual[key] !== value)) findings.push({ kind: "receipt_mismatch", entityId: row.id, repairable: false, actual: row.actual, expected });
}
findings.sort((a,b) => a.kind.localeCompare(b.kind) || a.entityId.localeCompare(b.entityId));
return { companyId, findings, fingerprint: receiptFingerprint({ companyId, findings }), checkedAt: new Date().toISOString() };
}
export function accountingIntegrityService(db: Db, hooks: BudgetServiceHooks = {}) {
return {
inspect: (companyId: string) => withAccountingReadSnapshot(db, companyId, tx => inspect(tx, companyId)),
repair: (companyId: string, fingerprint: string, reason: string, actorId: string) => withAccountingTransaction(db, companyId, async (tx, publications) => {
if (!reason.trim()) throw conflict("A repair reason is required");
const before = await inspect(tx, companyId);
if (before.fingerprint !== fingerprint) throw conflict("Accounting changed since inspection; inspect again before repairing");
for (const finding of before.findings.filter(f => f.repairable)) {
if (finding.kind === "company_projection") await tx.update(companies).set({ spentMonthlyCents: sql`${finding.expected.cents}::numeric`, updatedAt: new Date() }).where(eq(companies.id, companyId));
if (finding.kind === "agent_projection") await tx.update(agents).set({ spentMonthlyCents: sql`${finding.expected.cents}::numeric`, updatedAt: new Date() }).where(and(eq(agents.id, finding.entityId), eq(agents.companyId, companyId)));
if (finding.kind === "runtime_projection") await tx.update(agentRuntimeState).set({ totalCostCents: sql`${finding.expected.cents}::numeric`,
totalInputTokens: sql`${finding.expected.input}::bigint`, totalCachedInputTokens: sql`${finding.expected.cached}::bigint`, totalOutputTokens: sql`${finding.expected.output}::bigint`, updatedAt: new Date(),
}).where(and(eq(agentRuntimeState.agentId, finding.entityId), eq(agentRuntimeState.companyId, companyId)));
}
await logActivity(tx, { companyId, actorType: "user", actorId, action: "accounting.projections_repaired", entityType: "company", entityId: companyId,
details: { fingerprint, reason, repairs: before.findings.filter(f => f.repairable) } }, publications);
return inspect(tx, companyId);
}),
retry: async (companyId: string, runId: string, actorId: string) => {
const [run] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
if (!run) throw notFound("Accounting run not found");
await logActivity(db, { companyId, actorType: "user", actorId, action: "accounting.retry_requested", entityType: "heartbeat_run", entityId: runId });
try { return { accounted: await accountRunCost(db, runId, hooks) }; }
catch (error) {
await db.update(heartbeatRuns).set({ accountingLastAttemptAt: new Date(), accountingAttemptCount: sql`${heartbeatRuns.accountingAttemptCount} + 1`,
accountingLastError: error instanceof Error ? error.message.slice(0,1000) : "Accounting retry failed",
}).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId)));
throw error;
}
},
health: (companyId: string): Promise<AccountingHealth> => withAccountingReadSnapshot(db, companyId, async tx => {
const [counts] = await tx.execute<{ pending: number; unpriced: number; oldest: string | null; cancellations: number; reserved: string }>(sql`
select (select count(*)::int from heartbeat_runs where company_id = ${companyId} and cost_accounting_pending and status in ('succeeded','failed','timed_out','cancelled','interrupted')) as pending,
(select count(*)::int from cost_events where company_id = ${companyId} and cost_status = 'unpriced' and billing_type <> 'subscription_included') as unpriced,
(select min(coalesce(finished_at,created_at))::text from heartbeat_runs where company_id = ${companyId} and cost_accounting_pending and status in ('succeeded','failed','timed_out','cancelled','interrupted')) as oldest,
(select count(*)::int from budget_policies where company_id = ${companyId} and enforcement_version > enforcement_delivered_version) as cancellations,
(select coalesce(sum(amount_cents),0)::text from budget_reservations where company_id = ${companyId} and state = 'held') as reserved`);
const pending = await tx.execute<{ runId: string; agentId: string; state: "waiting_for_receipt" | "retryable"; lastError: string | null; attempts: number; since: string; lastAttemptAt: string | null }>(sql`
select id as "runId", agent_id as "agentId", case when usage_json->>'accountingReceiptReady' = 'false'
and usage_json->>'accountingProviderWorkStarted' is distinct from 'false'
and result_json->'executionRecovery'->>'providerWorkStarted' is distinct from 'false' then 'waiting_for_receipt' else 'retryable' end as state,
accounting_last_error as "lastError", accounting_attempt_count as attempts, coalesce(finished_at,created_at)::text as since, accounting_last_attempt_at::text as "lastAttemptAt"
from heartbeat_runs where company_id = ${companyId} and cost_accounting_pending and status in ('succeeded','failed','timed_out','cancelled','interrupted') order by coalesce(finished_at,created_at),id limit 100`);
const unpriced = await tx.execute<{ runId: string | null; costEventId: string; agentId: string; since: string }>(sql`
select heartbeat_run_id as "runId", id as "costEventId", agent_id as "agentId", occurred_at::text as since from cost_events
where company_id = ${companyId} and cost_status = 'unpriced' and billing_type <> 'subscription_included' order by occurred_at,id limit 100`);
return { companyId, pendingRunCount: counts.pending, unpricedEventCount: counts.unpriced, oldestPendingAt: counts.oldest, pendingCancellationCount: counts.cancellations,
heldReservationCents: normalizeCents(counts.reserved), items: [...pending, ...unpriced.map(row => ({ ...row, state: "unpriced" as const, lastError: null, attempts: 0, lastAttemptAt: null }))] };
}),
};
}
@@ -0,0 +1,40 @@
import { eq } from "drizzle-orm";
import { companies, type Db } from "@paperclipai/db";
import { publishActivity, type ActivityPublication } from "./activity-log.js";
import { notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
/** Serialize ledger and budget mutations in one company. Always acquire this
* before locking a run or a policy, so concurrent writers use one lock order.
* NO KEY UPDATE permits FK KEY SHARE locks held by recovery callers. */
export async function withAccountingTransaction<T>(db: Db, companyId: string, work: (tx: Db, publications: ActivityPublication[]) => Promise<T>): Promise<T> {
const publications: ActivityPublication[] = [];
const result = await db.transaction(async (transaction) => {
const tx = transaction as unknown as Db;
const [company] = await tx.select({ id: companies.id }).from(companies)
.where(eq(companies.id, companyId)).for("no key update");
if (!company) throw notFound("Company not found");
return work(tx, publications);
});
publishAccountingActivities(companyId, publications);
return result;
}
/** Consistent operator reports must not wait for or delay accounting writers.
* Reads see one committed snapshot; repairs still revalidate under the lock. */
export function withAccountingReadSnapshot<T>(db: Db, companyId: string, work: (tx: Db) => Promise<T>): Promise<T> {
return db.transaction(async transaction => {
const tx = transaction as unknown as Db;
const [company] = await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId));
if (!company) throw notFound("Company not found");
return work(tx);
}, { isolationLevel: "repeatable read", accessMode: "read only" });
}
/** Database history is durable even when the live subscriber channel fails. */
export function publishAccountingActivities(companyId: string, publications: ActivityPublication[]) {
for (const publication of publications) {
try { publishActivity(publication); }
catch (error) { logger.warn({ err: error, companyId }, "Accounting committed; live activity publication failed"); }
}
}
+17 -3
View File
@@ -1,3 +1,6 @@
import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import type { ActivityPublication } from "./activity-log.js";
import { agentAppearanceSchema, randomAgentAppearance, resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm";
@@ -342,7 +345,7 @@ export function deduplicateAgentName(
return `${candidateName} ${Date.now()}`;
}
export function agentService(db: Db) {
export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
const secretsSvc = secretService(db);
function currentUtcMonthWindow(now = new Date()) {
@@ -791,7 +794,7 @@ export function agentService(db: Db) {
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
type AgentUpdateResult = Awaited<ReturnType<typeof getById>>;
const applyUpdate = async (txDb: Db): Promise<AgentUpdateResult> => {
const applyUpdate = async (txDb: Db, publications: ActivityPublication[] = []): Promise<AgentUpdateResult> => {
const current = data.status !== undefined
? await txDb.select().from(agents).where(eq(agents.id, id)).for("update").then(rows => rows[0] ?? null)
: existing;
@@ -843,6 +846,11 @@ export function agentService(db: Db) {
);
}
if (normalizedPatch.budgetMonthlyCents !== undefined) {
await budgetServiceInTransaction(txDb, publications).upsertPolicy(existing.companyId, {
scopeType: "agent", scopeId: id, amount: normalizedPatch.budgetMonthlyCents, isActive: normalizedPatch.budgetMonthlyCents > 0, windowKind: "calendar_month_utc",
}, options?.recordRevision?.createdByUserId ?? null);
}
const normalizedUpdated = await agentService(txDb).getById(updated.id);
if (!normalizedUpdated) {
throw notFound("Agent not found");
@@ -869,6 +877,12 @@ export function agentService(db: Db) {
return normalizedUpdated;
};
if (normalizedPatch.budgetMonthlyCents !== undefined) {
const result = await withAccountingTransaction(db, existing.companyId, applyUpdate);
await deliverBudgetEnforcement(db, budgetHooks, existing.companyId);
return result;
}
const transaction = (db as unknown as {
transaction?: (callback: (tx: unknown) => Promise<AgentUpdateResult>) => Promise<AgentUpdateResult>;
}).transaction;
@@ -1075,7 +1089,7 @@ export function agentService(db: Db) {
});
}
return db.transaction(async (tx) => {
return withAccountingTransaction(db, existing.companyId, async (tx) => {
await tx
.select({ id: agents.id })
.from(agents)
+8 -4
View File
@@ -5,7 +5,8 @@ import { approvalComments, approvals } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js";
import { budgetService } from "./budgets.js";
import { budgetService, budgetServiceInTransaction } from "./budgets.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { notifyHireApproved } from "./hire-hook.js";
import { instanceSettingsService } from "./instance-settings.js";
@@ -142,10 +143,12 @@ export function approvalService(db: Db) {
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const now = new Date();
const result = await db.transaction(async tx => {
const txDb = tx as unknown as Db;
const existing = await getExistingApproval(id);
// Receipt writers lock company before agent. Hire approval must use that
// order too, including activation of an existing pending agent.
const result = await withAccountingTransaction(db, existing.companyId, async (txDb, publications) => {
const agentsSvc = agentService(txDb);
const budgets = budgetService(txDb);
const budgets = budgetServiceInTransaction(txDb, publications);
const { approval: updated, applied } = await resolveApproval(
id,
"approved",
@@ -210,6 +213,7 @@ export function approvalService(db: Db) {
return { approval: updated, applied, hireApprovedAgentId };
});
if (result.hireApprovedAgentId) {
await budgetService(db).deliverPendingEnforcement(result.approval.companyId);
void notifyHireApproved(db, {
companyId: result.approval.companyId,
agentId: result.hireApprovedAgentId,
@@ -0,0 +1,167 @@
import { createFinanceEventInTransaction } from "./finance.js";
import { and, desc, eq, sql } from "drizzle-orm";
import { agentRuntimeState, costEvents, costAdjustments, billingInvoices, billingInvoiceLines, financeEvents, heartbeatRuns, type Db } from "@paperclipai/db";
import { adjustCostSchema, importBillingInvoiceSchema, normalizeCents, subtractCents, type AdjustCost, type ImportBillingInvoice } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { withAccountingReadSnapshot, withAccountingTransaction } from "./accounting-transaction.js";
import { receiptFingerprint } from "./receipt-fingerprint.js";
import { logActivity } from "./activity-log.js";
import { updateMonthlySpendProjections } from "./costs.js";
import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
async function invoiceCostCandidates(tx: Db, companyId: string, biller: string, line: {
costEventId?: string | null; runId?: string | null; providerRequestId?: string | null; model?: string | null;
}) {
if (!(line.costEventId || line.runId || line.providerRequestId)) return [];
return tx.select({ id: costEvents.id, cents: sql<string>`${costEvents.costCents}::text`, status: costEvents.costStatus })
.from(costEvents).where(and(eq(costEvents.companyId, companyId), eq(costEvents.biller, biller),
line.costEventId ? eq(costEvents.id, line.costEventId) : undefined,
line.runId ? eq(costEvents.heartbeatRunId, line.runId) : undefined,
line.providerRequestId ? eq(costEvents.providerRequestId, line.providerRequestId) : undefined,
line.model ? eq(costEvents.model, line.model) : undefined)).limit(2);
}
export function billingReconciliationService(db: Db, hooks: BudgetServiceHooks = {}) {
return {
importInvoice: (companyId: string, raw: ImportBillingInvoice, actorId: string) => withAccountingTransaction(db, companyId, async (tx, publications) => {
const input = importBillingInvoiceSchema.parse(raw);
// Line order in an export is not part of invoice identity.
input.lines.sort((a,b) => a.externalId.localeCompare(b.externalId));
const receiptHash = receiptFingerprint(input);
const [existing] = await tx.select().from(billingInvoices).where(and(eq(billingInvoices.companyId, companyId), eq(billingInvoices.biller, input.biller), eq(billingInvoices.externalId, input.externalId)));
if (existing) {
if (existing.receiptHash !== receiptHash) throw conflict("Invoice identifier already has different contents");
return existing;
}
// Validate explicit references, even on non-USD invoices that we cannot
// compare with the USD cost ledger. Never infer cross-company matches.
for (const line of input.lines) {
if (line.costEventId) {
const [event] = await tx.select({ id: costEvents.id }).from(costEvents).where(and(eq(costEvents.companyId, companyId), eq(costEvents.id, line.costEventId)));
if (!event) throw notFound("Invoice cost event not found");
}
if (line.runId) {
const [run] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, line.runId)));
if (!run) throw notFound("Invoice run not found");
}
}
const [invoice] = await tx.insert(billingInvoices).values({ companyId, biller: input.biller, externalId: input.externalId, currency: input.currency, receiptHash, importedBy: actorId }).returning();
await tx.insert(billingInvoiceLines).values(input.lines.map(line => ({ ...line, companyId, invoiceId: invoice.id, occurredAt: new Date(line.occurredAt), pricing: line.pricing ?? {} })));
const matches = [];
const matchCounts = new Map<string, number>();
for (const line of input.lines) {
const candidates = line.kind === "inference" ? await invoiceCostCandidates(tx, companyId, input.biller, line) : [];
matches.push({ line, candidates });
if (candidates.length === 1) matchCounts.set(candidates[0].id, (matchCounts.get(candidates[0].id) ?? 0) + 1);
}
for (const { line, candidates } of matches) {
// Ambiguous evidence cannot authorize another debit. Keep every line
// for reconciliation, including differences from an existing charge.
if (candidates.length > 1 || candidates.length === 1 && matchCounts.get(candidates[0].id)! > 1) continue;
const costEventId = line.costEventId ?? candidates[0]?.id;
if (line.kind === "inference" && costEventId) {
const [recorded] = await tx.select({ id: financeEvents.id }).from(financeEvents).where(and(
eq(financeEvents.companyId, companyId), eq(financeEvents.costEventId, costEventId),
eq(financeEvents.biller, input.biller), eq(financeEvents.currency, input.currency),
eq(financeEvents.eventKind, "inference_charge"), eq(financeEvents.direction, "debit"),
sql`${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report'`,
)).limit(1);
if (recorded) continue;
}
await createFinanceEventInTransaction(tx, publications, companyId, {
idempotencyKey: `invoice:${invoice.id}:${receiptFingerprint(line.externalId)}`,
biller: input.biller, externalInvoiceId: input.externalId,
eventKind: line.kind === "inference" ? "inference_charge" : line.kind === "credit" ? "credit_refund" : "platform_fee",
direction: line.kind === "credit" ? "credit" : "debit",
amountCents: line.amountCents, currency: input.currency, occurredAt: new Date(line.occurredAt),
description: `Invoice ${input.externalId} · ${line.externalId}`.slice(0, 500),
costEventId, heartbeatRunId: line.runId,
metadataJson: { source: "provider_invoice", invoiceId: invoice.id, externalLineId: line.externalId },
}, { actorType: "user", actorId });
}
await logActivity(tx, { companyId, actorType: "user", actorId, action: "accounting.invoice_imported", entityType: "billing_invoice", entityId: invoice.id,
details: { biller: invoice.biller, externalId: invoice.externalId, currency: invoice.currency, lineCount: input.lines.length, receiptHash } }, publications);
return invoice;
}),
list: (companyId: string) => db.select().from(billingInvoices).where(eq(billingInvoices.companyId, companyId)).orderBy(desc(billingInvoices.createdAt)).limit(100),
reconcile: (companyId: string, invoiceId: string) => withAccountingReadSnapshot(db, companyId, async tx => {
const [invoice] = await tx.select().from(billingInvoices).where(and(eq(billingInvoices.companyId, companyId), eq(billingInvoices.id, invoiceId)));
if (!invoice) throw notFound("Invoice not found");
const lines = await tx.select().from(billingInvoiceLines).where(and(eq(billingInvoiceLines.companyId, companyId), eq(billingInvoiceLines.invoiceId, invoiceId))).orderBy(billingInvoiceLines.externalId);
const rows = [];
for (const line of lines) {
const candidates = invoice.currency !== "USD" || line.kind !== "inference" ? []
: await invoiceCostCandidates(tx, companyId, invoice.biller, line);
const match = candidates.length === 1 ? candidates[0] : null;
const differenceCents = match ? subtractCents(line.amountCents, match.cents) : null;
const status = invoice.currency !== "USD" ? "unsupported_currency" : line.kind !== "inference" ? "non_inference"
: candidates.length > 1 ? "ambiguous" : !match ? "unmatched" : differenceCents === "0.0000000" && match.status !== "unpriced" ? "matched" : "difference";
rows.push({ ...line, amountCents: normalizeCents(line.amountCents), status, matchedEventId: match?.id ?? null,
recordedCents: match ? normalizeCents(match.cents) : null, differenceCents });
}
// Multiple invoice lines cannot independently revalue the same charge.
const counts = new Map<string, number>();
for (const row of rows) if (row.matchedEventId) counts.set(row.matchedEventId, (counts.get(row.matchedEventId) ?? 0) + 1);
for (const row of rows) if (row.matchedEventId && counts.get(row.matchedEventId)! > 1) row.status = "ambiguous";
return { invoice, lines: rows };
}),
adjustments: (companyId: string, eventId: string) => db.select().from(costAdjustments).where(and(eq(costAdjustments.companyId, companyId), eq(costAdjustments.costEventId, eventId))).orderBy(desc(costAdjustments.createdAt)),
adjust: async (companyId: string, eventId: string, raw: AdjustCost, actorId: string) => {
const input = adjustCostSchema.parse(raw);
const result = await withAccountingTransaction(db, companyId, async (tx, publications) => {
const hash = receiptFingerprint({ eventId, ...input });
const [existing] = await tx.select().from(costAdjustments).where(and(eq(costAdjustments.companyId, companyId), eq(costAdjustments.idempotencyKey, input.idempotencyKey)));
if (existing) {
if (existing.receiptHash !== hash) throw conflict("Correction key already has different contents");
return existing;
}
const [event] = await tx.select({ event: costEvents, exact: sql<string>`${costEvents.costCents}::text` }).from(costEvents)
.where(and(eq(costEvents.companyId, companyId), eq(costEvents.id, eventId))).for("update");
if (!event) throw notFound("Cost event not found");
if (normalizeCents(event.exact) !== input.expectedCents) throw conflict("Charge changed since review; review it again");
if (input.invoiceLineId) {
const reportLine = await tx.select({ line: billingInvoiceLines, invoice: billingInvoices }).from(billingInvoiceLines)
.innerJoin(billingInvoices, eq(billingInvoices.id, billingInvoiceLines.invoiceId)).where(and(eq(billingInvoiceLines.id, input.invoiceLineId), eq(billingInvoiceLines.companyId, companyId), eq(billingInvoices.companyId, companyId)));
const row = reportLine[0];
if (!row) throw notFound("Invoice line not found");
if (row.invoice.currency !== "USD" || row.line.kind !== "inference" || row.invoice.biller !== event.event.biller
|| normalizeCents(row.line.amountCents) !== input.correctedCents
|| row.line.costEventId && row.line.costEventId !== eventId
|| row.line.runId && row.line.runId !== event.event.heartbeatRunId
|| row.line.providerRequestId && row.line.providerRequestId !== event.event.providerRequestId
|| row.line.model && row.line.model !== event.event.model) throw unprocessable("Invoice line does not support this correction");
const review = await billingReconciliationService(tx).reconcile(companyId, row.invoice.id);
const reviewedLine = review.lines.find(line => line.id === input.invoiceLineId);
if (!reviewedLine || !["matched", "difference"].includes(reviewedLine.status) || reviewedLine.matchedEventId !== eventId)
throw unprocessable("Invoice match is ambiguous or unverified; review an explicit correction without attaching this line");
const [used] = await tx.select().from(costAdjustments).where(and(eq(costAdjustments.companyId, companyId), eq(costAdjustments.invoiceLineId, input.invoiceLineId)));
if (used) throw conflict("Invoice line has already been applied");
}
const [adjustment] = await tx.insert(costAdjustments).values({ companyId, costEventId: eventId, invoiceLineId: input.invoiceLineId,
idempotencyKey: input.idempotencyKey, receiptHash: hash, previousCents: input.expectedCents, correctedCents: input.correctedCents,
previousStatus: event.event.costStatus, previousPricing: event.event.pricingProvenance ?? {},
reason: input.reason, actorId, pricing: input.pricing }).returning();
// Original provider value and fingerprint remain immutable. cost_cents
// is the effective valuation, backed by this append-only correction.
const [updated] = await tx.update(costEvents).set({ costCents: sql`${input.correctedCents}::numeric`,
reportedCostCents: event.event.reportedCostCents ?? normalizeCents(event.exact), costStatus: input.pricing.source === "rate_card" ? "estimated" : "reported", pricingProvenance: input.pricing,
}).where(eq(costEvents.id, eventId)).returning();
await updateMonthlySpendProjections(tx, companyId, updated.agentId, subtractCents(input.correctedCents, input.expectedCents), updated.occurredAt);
if (updated.heartbeatRunId) {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, updated.heartbeatRunId), eq(heartbeatRuns.companyId, companyId)));
if (run?.costAccountedAt && run.accountingProjectionVersion === "v2") await tx.update(agentRuntimeState)
.set({ totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${subtractCents(input.correctedCents, input.expectedCents)}::numeric`, updatedAt: new Date() })
.where(and(eq(agentRuntimeState.agentId, updated.agentId), eq(agentRuntimeState.companyId, companyId)));
}
const budgets = budgetServiceInTransaction(tx, publications);
await budgets.evaluateCostEvent(updated);
await budgets.getInvocationBlock(companyId, updated.agentId, { projectId: updated.projectId });
await logActivity(tx, { companyId, actorType: "user", actorId, action: "accounting.charge_corrected", entityType: "cost_event", entityId: eventId,
details: { adjustmentId: adjustment.id, previousCents: input.expectedCents, correctedCents: input.correctedCents, reason: input.reason, pricing: input.pricing } }, publications);
return adjustment;
});
await deliverBudgetEnforcement(db, hooks, companyId);
return result;
},
};
}
+23 -44
View File
@@ -16,9 +16,7 @@ import {
issues,
heartbeatRuns,
agents,
companies,
costEvents,
financeEvents,
} from "@paperclipai/db";
import {
BROWSER_USE_IDLE_MS,
@@ -41,7 +39,9 @@ import { secretService } from "./secrets.js";
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
import { issueTreeControlService } from "./issue-tree-control.js";
import { accessService } from "./access.js";
import { getMonthlySpendTotal } from "./costs.js";
import { createCostEventInTransaction } from "./costs.js";
import { createFinanceEventInTransaction } from "./finance.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { logActivity } from "./activity-log.js";
import { browserUseViewports } from "./browser-use-viewport.js";
import { forbidden, notFound, conflict } from "../errors.js";
@@ -911,36 +911,32 @@ export function browserUseService(
const cents = Math.max(0, Math.round(Number(summary.totalCostUsd) * 100));
if (!Number.isSafeInteger(cents))
throw new BrowserUseError(502, "Browser Use returned invalid usage.");
const [issue] = await db
.select({ projectId: issues.projectId })
.from(issues)
.where(and(eq(issues.id, s.issueId), eq(issues.companyId, s.companyId)));
const event = await db.transaction(async (tx) => {
const event = await withAccountingTransaction(db, s.companyId, async (tx, publications) => {
const [issue] = await tx
.select({ projectId: issues.projectId })
.from(issues)
.where(and(eq(issues.id, s.issueId), eq(issues.companyId, s.companyId)));
const [current] = await tx
.select()
.from(runs)
.where(eq(runs.id, run.id))
.for("update");
if (cents <= current.accountedCents) return null;
const [e] = await tx
.insert(costEvents)
.values({
companyId: s.companyId,
agentId: s.agentId,
issueId: s.issueId,
projectId: issue?.projectId,
heartbeatRunId: run.heartbeatRunId,
provider: "browser-use-cloud",
biller: "browser-use-cloud",
billingType: "metered_api",
model: summary.model,
costCents: cents - current.accountedCents,
occurredAt: new Date(),
billingCode: `browser-use-cloud:${run.id}`,
})
.returning();
await tx.insert(financeEvents).values({
companyId: e.companyId,
const e = await createCostEventInTransaction(tx, s.companyId, {
agentId: s.agentId,
issueId: s.issueId,
projectId: issue?.projectId,
heartbeatRunId: run.heartbeatRunId,
provider: "browser-use-cloud",
biller: "browser-use-cloud",
billingType: "metered_api",
model: summary.model,
costCents: cents - current.accountedCents,
occurredAt: new Date(),
billingCode: `browser-use-cloud:${run.id}`,
idempotencyKey: `browser-use-cloud:${run.id}:${cents}`,
}, publications);
await createFinanceEventInTransaction(tx, publications, s.companyId, {
agentId: e.agentId,
issueId: e.issueId,
projectId: e.projectId,
@@ -963,23 +959,6 @@ export function browserUseService(
.where(eq(runs.id, run.id));
return e;
});
await db
.update(agents)
.set({
spentMonthlyCents: await getMonthlySpendTotal(db, {
companyId: s.companyId,
agentId: s.agentId,
}),
})
.where(and(eq(agents.companyId, s.companyId), eq(agents.id, s.agentId)));
await db
.update(companies)
.set({
spentMonthlyCents: await getMonthlySpendTotal(db, {
companyId: s.companyId,
}),
})
.where(eq(companies.id, s.companyId));
// Re-evaluate on retries too: accounting may have committed immediately
// before a crash interrupted budget enforcement.
const lastEvent =
@@ -0,0 +1,68 @@
import { and, eq, sql } from "drizzle-orm";
import { agents, budgetPolicies, budgetReservations, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { centsToUnits, unitsToCents } from "@paperclipai/shared";
import { conflict, notFound } from "../errors.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { budgetServiceInTransaction, budgetPoliciesForRun, computeObservedSpend } from "./budgets.js";
/** Reserve before dispatch, under the same company lock as charges and policy
* changes. Estimates constrain admission; they cannot cap a provider's bill.
* A reservation survives timeouts and restarts until accounting proves closure. */
export async function reserveRunBudget(db: Db, companyId: string, runId: string, projectId: string | null, ledgerScope: Record<string, unknown> = {}, recoveryLeaseOwner?: string) {
return withAccountingTransaction(db, companyId, async (tx, publications) => {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId))).for("update");
if (!run) throw notFound("Run not found");
if (run.resultJson?.cancellation || run.resultJson?.startupCancellation) throw conflict("Run cancellation already requested");
if (run.costAccountedAt || !["queued", "running"].includes(run.status)) throw conflict("Run can no longer start provider work");
const [existing] = await tx.select().from(budgetReservations).where(and(eq(budgetReservations.companyId, companyId), eq(budgetReservations.runId, runId)));
if (existing) {
// The native coordinator fences recovery ownership. Reuse the original
// hold only for that live owner, never for an ordinary duplicate dispatch.
const [owner] = recoveryLeaseOwner && run.runtimeMode === "native" && existing.state === "held"
&& existing.projectId === projectId ? await tx.select({ runId: nativeRunFinalizations.runId })
.from(nativeRunFinalizations).where(and(eq(nativeRunFinalizations.companyId, companyId),
eq(nativeRunFinalizations.runId, runId), eq(nativeRunFinalizations.leaseOwner, recoveryLeaseOwner),
sql`${nativeRunFinalizations.leaseExpiresAt} > now()`)).for("update") : [];
if (owner) {
// Recovery has already made this run queued/running, so its own pending
// accounting is excluded. Current overruns, other unfinished runs and
// scope pauses still apply before any more paid work starts.
const block = await budgetServiceInTransaction(tx, publications).getInvocationBlock(companyId, run.agentId, { projectId });
if (block) throw conflict(block.reason);
const [agent] = await tx.select({ status: agents.status }).from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, run.agentId)));
if (agent?.status === "paused") throw conflict("Agent is paused and cannot start new work");
return { ...existing, reused: true };
}
throw conflict("Provider dispatch has already reserved this run");
}
const block = await budgetServiceInTransaction(tx, publications).getInvocationBlock(companyId, run.agentId, { projectId });
if (block) throw conflict(block.reason);
const policies = await tx.select().from(budgetPolicies).where(and(
eq(budgetPolicies.companyId, companyId), eq(budgetPolicies.isActive, true), eq(budgetPolicies.hardStopEnabled, true),
eq(budgetPolicies.metric, "billed_cents"), sql`${budgetPolicies.amount} > 0`,
budgetPoliciesForRun(companyId, run.agentId, projectId),
));
const amount = policies.reduce((max, policy) => { const next = centsToUnits(policy.reservationCents); return next > max ? next : max; }, 0n);
for (const policy of policies) {
const [held] = await tx.select({ amount: sql<string>`coalesce(sum(${budgetReservations.amountCents}), 0)::text` }).from(budgetReservations).where(and(
eq(budgetReservations.companyId, companyId), eq(budgetReservations.state, "held"),
policy.scopeType === "agent" ? eq(budgetReservations.agentId, run.agentId) : undefined,
policy.scopeType === "project" ? eq(budgetReservations.projectId, projectId!) : undefined,
));
const observed = await computeObservedSpend(tx, policy);
// Even zero-estimate runs cannot enter a scope whose available capacity
// is completely reserved. Outstanding reservations carry across months.
const committed = centsToUnits(observed.totalExact) + centsToUnits(held.amount);
const limit = centsToUnits(policy.amount);
if (committed >= limit || committed + amount > limit) throw conflict("Available budget is reserved by unfinished runs");
}
// A zero-valued row also fences duplicate dispatch when estimates are off.
const [reservation] = await tx.insert(budgetReservations).values({ companyId, runId, agentId: run.agentId, projectId,
amountCents: unitsToCents(amount), providerStartedAt: new Date() }).returning();
await tx.update(heartbeatRuns).set({ costAccountingPending: true,
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ accountingReceiptReady: false, ledgerScope: { ...(run.usageJson?.ledgerScope as object ?? {}), ...ledgerScope, projectId } })}::jsonb`,
}).where(eq(heartbeatRuns.id, runId));
return { ...reservation, reused: false };
});
}
+336 -246
View File
@@ -1,4 +1,5 @@
import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
import { compareCents, normalizeCents } from "@paperclipai/shared";
import { and, desc, eq, gte, inArray, lt, ne, sql, or, isNull, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { recordAgentStatusEvent } from "./resource-lifecycle-events.js";
import {
@@ -8,6 +9,8 @@ import {
budgetPolicies,
companies,
costEvents,
heartbeatRuns,
nativeRunFinalizations,
projects,
} from "@paperclipai/db";
import type {
@@ -23,8 +26,10 @@ import type {
BudgetThresholdType,
BudgetWindowKind,
} from "@paperclipai/shared";
import { notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import { HttpError, notFound, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { logActivity, type LogActivityInput, type ActivityPublication } from "./activity-log.js";
type ScopeRecord = {
companyId: string;
@@ -40,6 +45,10 @@ export type BudgetEnforcementScope = {
companyId: string;
scopeType: BudgetScopeType;
scopeId: string;
/** Snapshot taken under the company lock, before a later budget grant can admit new work. */
createdBefore?: Date;
/** Revalidated at the durable cancellation write, under the admission lock. */
enforcement?: { policyId: string; version: number };
};
export type BudgetServiceHooks = {
@@ -65,13 +74,13 @@ function resolveWindow(windowKind: BudgetWindowKind, now = new Date()) {
}
function budgetStatusFromObserved(
observedAmount: number,
observedAmount: string,
amount: number,
warnPercent: number,
): BudgetPolicySummary["status"] {
if (amount <= 0) return "ok";
if (observedAmount >= amount) return "hard_stop";
if (observedAmount >= Math.ceil((amount * warnPercent) / 100)) return "warning";
if (compareCents(observedAmount, amount) >= 0) return "hard_stop";
if (compareCents(observedAmount, Math.ceil((amount * warnPercent) / 100)) >= 0) return "warning";
return "ok";
}
@@ -141,29 +150,84 @@ async function resolveScopeRecord(db: Db, scopeType: BudgetScopeType, scopeId: s
};
}
async function computeObservedAmount(
db: Db,
policy: Pick<PolicyRow, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">,
) {
if (policy.metric !== "billed_cents") return 0;
const conditions = [eq(costEvents.companyId, policy.companyId)];
if (policy.scopeType === "agent") conditions.push(eq(costEvents.agentId, policy.scopeId));
if (policy.scopeType === "project") conditions.push(eq(costEvents.projectId, policy.scopeId));
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
if (policy.windowKind === "calendar_month_utc") {
conditions.push(gte(costEvents.occurredAt, start));
conditions.push(lt(costEvents.occurredAt, end));
// Keep a large dashboard from filling the database pool with queued reads.
async function mapBudgetReads<T, R>(rows: T[], read: (row: T) => Promise<R>): Promise<R[]> {
const result: R[] = [];
for (let start = 0; start < rows.length; start += 4) {
result.push(...await Promise.all(rows.slice(start, start + 4).map(read)));
}
return result;
}
const [row] = await db
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
})
.from(costEvents)
.where(and(...conditions));
/** Filter in PostgreSQL so one run never loads unrelated agents' policies. */
export function budgetPoliciesForRun(companyId: string, agentId: string, projectId: string | null) {
return or(
and(eq(budgetPolicies.scopeType, "company"), eq(budgetPolicies.scopeId, companyId)),
and(eq(budgetPolicies.scopeType, "agent"), eq(budgetPolicies.scopeId, agentId)),
projectId ? and(eq(budgetPolicies.scopeType, "project"), eq(budgetPolicies.scopeId, projectId)) : undefined,
);
}
return Number(row?.total ?? 0);
type SpendPolicy = Pick<PolicyRow, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">;
type ObservedSpend = { windowStart: Date; windowEnd: Date; total: number; totalExact: string; unpricedEventCount: number; pendingRunCount: number; recoveringRunCount: number };
/** One ledger scan and one pending-run scan for the affected policies. The
* company lock still protects decisions; nothing is cached across mutations.
* Each aggregate keeps its own scope/window, including mixed lifetime and
* monthly policies. An outer predicate excludes rows no policy needs. */
async function computeObservedSpends(db: Db, policies: SpendPolicy[]): Promise<ObservedSpend[]> {
const now = new Date();
const windows = policies.map(policy => resolveWindow(policy.windowKind as BudgetWindowKind, now));
const billable = policies.map((policy, index) => ({ policy, index })).filter(({ policy }) => policy.metric === "billed_cents");
const ledgerColumns: Record<string, SQL> = {};
const pendingColumns: Record<string, SQL> = {};
const ledgerPredicates: SQL[] = [], pendingPredicates: SQL[] = [];
for (const { policy, index } of billable) {
const { start, end } = windows[index];
const ledger = and(
eq(costEvents.companyId, policy.companyId),
policy.scopeType === "agent" ? eq(costEvents.agentId, policy.scopeId) : undefined,
policy.scopeType === "project" ? eq(costEvents.projectId, policy.scopeId) : undefined,
policy.windowKind === "calendar_month_utc" ? and(gte(costEvents.occurredAt, start), lt(costEvents.occurredAt, end)) : undefined,
)!;
const pending = and(
eq(heartbeatRuns.companyId, policy.companyId), eq(heartbeatRuns.costAccountingPending, true),
inArray(heartbeatRuns.status, ["succeeded", "failed", "timed_out", "cancelled", "interrupted"]),
policy.scopeType === "agent" ? eq(heartbeatRuns.agentId, policy.scopeId) : undefined,
policy.scopeType === "project" ? sql`${heartbeatRuns.usageJson}->'ledgerScope'->>'projectId' = ${policy.scopeId}` : undefined,
policy.windowKind === "calendar_month_utc" ? sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${start.toISOString()}::timestamptz
and coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) < ${end.toISOString()}::timestamptz` : undefined,
)!;
ledgerPredicates.push(ledger); pendingPredicates.push(pending);
ledgerColumns[`total${index}`] = sql`coalesce(sum(${costEvents.costCents}) filter (where ${ledger}), 0)::text`;
ledgerColumns[`unpriced${index}`] = sql`count(*) filter (where ${ledger} and ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`;
pendingColumns[`pending${index}`] = sql`count(*) filter (where ${pending})::int`;
pendingColumns[`recovering${index}`] = sql`count(*) filter (where ${pending} and ${heartbeatRuns.runtimeMode} = 'native'
and exists (select 1 from ${nativeRunFinalizations} where ${nativeRunFinalizations.runId} = ${heartbeatRuns.id}
and ${nativeRunFinalizations.companyId} = ${heartbeatRuns.companyId}
and ${nativeRunFinalizations.resultId} is null and ${nativeRunFinalizations.phase} <> 'terminal_failure'))::int`;
}
const [ledger = {}] = billable.length ? await db.select(ledgerColumns).from(costEvents).where(or(...ledgerPredicates)) : [];
const [pending = {}] = billable.length ? await db.select(pendingColumns).from(heartbeatRuns).where(or(...pendingPredicates)) : [];
return policies.map((_, index) => ({
windowStart: windows[index].start, windowEnd: windows[index].end,
total: Number(ledger[`total${index}`] ?? 0), totalExact: normalizeCents(String(ledger[`total${index}`] ?? 0)),
unpricedEventCount: Number(ledger[`unpriced${index}`] ?? 0), pendingRunCount: Number(pending[`pending${index}`] ?? 0),
recoveringRunCount: Number(pending[`recovering${index}`] ?? 0),
}));
}
export async function computeObservedSpend(db: Db, policy: SpendPolicy) {
return (await computeObservedSpends(db, [policy]))[0];
}
function observedBlocks(policy: PolicyRow, observed: Awaited<ReturnType<typeof computeObservedSpend>>) {
if (!policy.isActive || !policy.hardStopEnabled || policy.amount <= 0) return false;
return compareCents(observed.totalExact, policy.amount) >= 0 || (observed.pendingRunCount - observed.recoveringRunCount) > 0 || (policy.unpricedUsagePolicy !== "allow" && observed.unpricedEventCount > 0);
}
async function policyBlocks(db: Db, policy: PolicyRow) {
if (!policy.isActive || !policy.hardStopEnabled || policy.amount <= 0) return false;
return observedBlocks(policy, await computeObservedSpend(db, policy));
}
function buildApprovalPayload(input: {
@@ -171,6 +235,7 @@ function buildApprovalPayload(input: {
scopeName: string;
thresholdType: BudgetThresholdType;
amountObserved: number;
amountObservedExact: string;
windowStart: Date;
windowEnd: Date;
}) {
@@ -183,11 +248,12 @@ function buildApprovalPayload(input: {
thresholdType: input.thresholdType,
budgetAmount: input.policy.amount,
observedAmount: input.amountObserved,
observedAmountExact: input.amountObservedExact,
warnPercent: input.policy.warnPercent,
windowStart: input.windowStart.toISOString(),
windowEnd: input.windowEnd.toISOString(),
policyId: input.policy.id,
guidance: "Raise the budget and resume the scope, or keep the scope paused.",
guidance: "Resolve pending or unpriced accounting, raise the budget, or keep the scope paused.",
};
}
@@ -211,7 +277,9 @@ async function markApprovalStatus(
.where(eq(approvals.id, approvalId));
}
export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
/** Internal: caller must hold the company accounting transaction lock. */
export function budgetServiceInTransaction(db: Db, publications: ActivityPublication[] = []) {
const recordActivity = (input: LogActivityInput) => logActivity(db, input, publications);
async function pauseScopeForBudget(policy: PolicyRow) {
const now = new Date();
if (policy.scopeType === "agent") {
@@ -235,7 +303,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
pausedAt: now,
updatedAt: now,
})
.where(eq(projects.id, policy.scopeId));
.where(and(eq(projects.id, policy.scopeId), or(isNull(projects.pausedAt), eq(projects.pauseReason, "budget"))));
return;
}
@@ -247,19 +315,24 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
pausedAt: now,
updatedAt: now,
})
.where(eq(companies.id, policy.scopeId));
.where(and(eq(companies.id, policy.scopeId), or(eq(companies.status, "active"), and(eq(companies.status, "paused"), eq(companies.pauseReason, "budget")))));
}
async function pauseAndCancelScopeForBudget(policy: PolicyRow) {
await pauseScopeForBudget(policy);
await hooks.cancelWorkForScope?.({
companyId: policy.companyId,
scopeType: policy.scopeType as BudgetScopeType,
scopeId: policy.scopeId,
});
await db.update(budgetPolicies).set({
enforcementVersion: sql`${budgetPolicies.enforcementVersion} + 1`,
}).where(eq(budgetPolicies.id, policy.id));
}
async function resumeScopeFromBudget(policy: PolicyRow) {
const policies = await db.select().from(budgetPolicies).where(and(
eq(budgetPolicies.companyId, policy.companyId), eq(budgetPolicies.scopeType, policy.scopeType),
eq(budgetPolicies.scopeId, policy.scopeId), eq(budgetPolicies.isActive, true), eq(budgetPolicies.hardStopEnabled, true),
));
for (const candidate of policies) {
if (await policyBlocks(db, candidate)) return;
}
const now = new Date();
if (policy.scopeType === "agent") {
await db.transaction(async tx => {
@@ -294,7 +367,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
pausedAt: null,
updatedAt: now,
})
.where(and(eq(companies.id, policy.scopeId), eq(companies.pauseReason, "budget")));
.where(and(eq(companies.id, policy.scopeId), eq(companies.status, "paused"), eq(companies.pauseReason, "budget")));
}
async function getPolicyRow(policyId: string) {
@@ -317,8 +390,9 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
async function buildPolicySummary(policy: PolicyRow): Promise<BudgetPolicySummary> {
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
const observedAmount = await computeObservedAmount(db, policy);
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
const observed = await computeObservedSpend(db, policy);
const { total: observedAmount, totalExact: observedAmountExact, unpricedEventCount, pendingRunCount } = observed;
const { windowStart: start, windowEnd: end } = observed;
const amount = policy.isActive ? policy.amount : 0;
const utilizationPercent =
amount > 0 ? Number(((observedAmount / amount) * 100).toFixed(2)) : 0;
@@ -332,14 +406,19 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
windowKind: policy.windowKind as BudgetWindowKind,
amount,
observedAmount,
observedAmountExact,
reservationCents: policy.reservationCents,
unpricedEventCount,
pendingRunCount,
unpricedUsagePolicy: policy.unpricedUsagePolicy as "block" | "allow",
remainingAmount: amount > 0 ? Math.max(0, amount - observedAmount) : 0,
utilizationPercent,
warnPercent: policy.warnPercent,
hardStopEnabled: policy.hardStopEnabled,
notifyEnabled: policy.notifyEnabled,
isActive: policy.isActive,
status: policy.isActive
? budgetStatusFromObserved(observedAmount, amount, policy.warnPercent)
status: observedBlocks(policy, observed) ? "hard_stop" : policy.isActive
? budgetStatusFromObserved(observedAmountExact, amount, policy.warnPercent)
: "ok",
paused: scope.paused,
pauseReason: scope.pauseReason,
@@ -351,9 +430,9 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
async function createIncidentIfNeeded(
policy: PolicyRow,
thresholdType: BudgetThresholdType,
amountObserved: number,
observed: Awaited<ReturnType<typeof computeObservedSpend>>,
) {
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
const { windowStart: start, windowEnd: end } = observed;
const existing = await db
.select()
.from(budgetIncidents)
@@ -362,18 +441,21 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
eq(budgetIncidents.policyId, policy.id),
eq(budgetIncidents.windowStart, start),
eq(budgetIncidents.thresholdType, thresholdType),
ne(budgetIncidents.status, "dismissed"),
or(eq(budgetIncidents.status, "open"), and(eq(budgetIncidents.status, "dismissed"), eq(budgetIncidents.amountLimit, policy.amount))),
),
)
.orderBy(desc(budgetIncidents.createdAt))
.then((rows) => rows[0] ?? null);
if (existing) return { incident: existing, created: false };
const { total: amountObserved, totalExact: amountObservedExact } = observed;
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
const payload = buildApprovalPayload({
policy,
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
thresholdType,
amountObserved,
amountObservedExact,
windowStart: start,
windowEnd: end,
});
@@ -406,7 +488,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
windowEnd: end,
thresholdType,
amountLimit: policy.amount,
amountObserved,
amountObserved: sql`${amountObservedExact}::numeric`,
status: "open",
approvalId: approval?.id ?? null,
})
@@ -436,11 +518,14 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
policyId: string,
approvalStatus: "approved" | "rejected" | null,
decidedByUserId: string | null,
thresholdType?: BudgetThresholdType,
) {
const condition = and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open"),
thresholdType ? eq(budgetIncidents.thresholdType, thresholdType) : undefined);
const openRows = await db
.select()
.from(budgetIncidents)
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
.where(condition);
await db
.update(budgetIncidents)
@@ -449,7 +534,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
.where(condition);
if (!approvalStatus || !decidedByUserId) return;
for (const row of openRows) {
@@ -467,40 +552,87 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
: [];
const approvalStatusById = new Map(approvalRows.map((row) => [row.id, row.status]));
return Promise.all(
rows.map(async (row) => {
const scope = await resolveScopeRecord(db, row.scopeType as BudgetScopeType, row.scopeId);
return {
id: row.id,
companyId: row.companyId,
policyId: row.policyId,
scopeType: row.scopeType as BudgetScopeType,
scopeId: row.scopeId,
scopeName: normalizeScopeName(row.scopeType as BudgetScopeType, scope.name),
metric: row.metric as BudgetMetric,
windowKind: row.windowKind as BudgetWindowKind,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
thresholdType: row.thresholdType as BudgetThresholdType,
amountLimit: row.amountLimit,
amountObserved: row.amountObserved,
status: row.status as BudgetIncident["status"],
approvalId: row.approvalId ?? null,
approvalStatus: row.approvalId ? approvalStatusById.get(row.approvalId) ?? null : null,
resolvedAt: row.resolvedAt ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}),
);
return mapBudgetReads(rows, async (row) => {
const scope = await resolveScopeRecord(db, row.scopeType as BudgetScopeType, row.scopeId);
return {
id: row.id,
companyId: row.companyId,
policyId: row.policyId,
scopeType: row.scopeType as BudgetScopeType,
scopeId: row.scopeId,
scopeName: normalizeScopeName(row.scopeType as BudgetScopeType, scope.name),
metric: row.metric as BudgetMetric,
windowKind: row.windowKind as BudgetWindowKind,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
thresholdType: row.thresholdType as BudgetThresholdType,
amountLimit: row.amountLimit,
amountObserved: row.amountObserved,
status: row.status as BudgetIncident["status"],
approvalId: row.approvalId ?? null,
approvalStatus: row.approvalId ? approvalStatusById.get(row.approvalId) ?? null : null,
resolvedAt: row.resolvedAt ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
});
}
async function reconcileScope(companyId: string, scopeType: BudgetScopeType, scopeId: string) {
const scope = await resolveScopeRecord(db, scopeType, scopeId);
if (scope.companyId !== companyId) throw notFound("Budget scope not found");
const policies = await db.select().from(budgetPolicies).where(and(
eq(budgetPolicies.companyId, companyId), eq(budgetPolicies.scopeType, scopeType), eq(budgetPolicies.scopeId, scopeId),
));
let recoveringPolicy: PolicyRow | undefined;
for (const policy of policies) {
const observed = await computeObservedSpend(db, policy);
if (observedBlocks(policy, observed)) {
await createIncidentIfNeeded(policy, "hard", observed);
await pauseAndCancelScopeForBudget(policy);
const title = scopeType === "company" ? "Company" : scopeType === "agent" ? "Agent" : "Project";
if (observed.pendingRunCount > 0) {
return { scopeType, scopeId, scopeName: scope.name, reason: `${title} cannot start work while completed runs await accounting.` };
}
if (policy.unpricedUsagePolicy !== "allow" && observed.unpricedEventCount > 0) {
return { scopeType, scopeId, scopeName: scope.name, reason: `${title} cannot start work because recorded usage has no reliable price.` };
}
return { scopeType, scopeId, scopeName: scope.name, reason: scopeType === "project"
? "Project cannot start work because its budget hard-stop is still exceeded."
: `${title} is paused because its budget hard-stop was reached.` };
}
// An open native coordinator must finish the original run's accounting.
// Deny fresh work without pausing/cancelling that recovery or its hold.
if (policy.isActive && policy.hardStopEnabled && policy.amount > 0 && observed.recoveringRunCount > 0) recoveringPolicy ??= policy;
// A closed UTC window, inactive policy, or budget raise releases only
// budget-owned pauses. Manual pauses remain operator decisions.
await resolveOpenIncidentsForPolicy(policy.id, "approved", "budget_service", "hard");
if (!policy.isActive || !policy.notifyEnabled || compareCents(observed.totalExact, Math.ceil(policy.amount * policy.warnPercent / 100)) < 0) {
await resolveOpenIncidentsForPolicy(policy.id, "approved", "budget_service", "soft");
}
}
// Agent status has its own admission gate and error code. Preserve a
// manual pause without misclassifying it as a budget failure.
if (scopeType !== "agent" && scope.paused && scope.pauseReason !== "budget") {
return { scopeType, scopeId, scopeName: scope.name, reason: `${scopeType === "company" ? "Company" : "Project"} is paused and cannot start new work.` };
}
if (scope.paused && scope.pauseReason === "budget" && !policies.length) {
return { scopeType, scopeId, scopeName: scope.name, reason: "Budget pause requires a policy or an explicit operator resume." };
}
if (scope.pauseReason === "budget" && policies[0]) await resumeScopeFromBudget(policies[0]);
if (recoveringPolicy) return { scopeType, scopeId, scopeName: scope.name,
reason: "New work must wait while a native run recovers its unfinished accounting." };
return null;
}
return {
reconcileScope,
listPolicies: async (companyId: string): Promise<BudgetPolicy[]> => {
const rows = await listPolicyRows(companyId);
return rows.map((row) => ({
...row,
scopeType: row.scopeType as BudgetScopeType,
unpricedUsagePolicy: row.unpricedUsagePolicy as "block" | "allow",
metric: row.metric as BudgetMetric,
windowKind: row.windowKind as BudgetWindowKind,
}));
@@ -516,10 +648,9 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
throw unprocessable("Budget scope does not belong to company");
}
if (input.reservationCents !== undefined && compareCents(input.reservationCents, 0) < 0) throw unprocessable("Reservation cannot be negative");
const metric = input.metric ?? "billed_cents";
const windowKind = input.windowKind ?? (input.scopeType === "project" ? "lifetime" : "calendar_month_utc");
const amount = Math.max(0, Math.floor(input.amount));
const nextIsActive = amount > 0 && (input.isActive ?? true);
const existing = await db
.select()
.from(budgetPolicies)
@@ -534,15 +665,22 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
)
.then((rows) => rows[0] ?? null);
if (!existing && input.amount === undefined) throw unprocessable("Amount is required for a new budget policy");
const amount = Math.max(0, Math.floor(input.amount ?? existing!.amount));
// A saved zero disables enforcement. Raising it is an explicit limit
// setting; editing a disabled positive policy must preserve that choice.
const nextIsActive = amount > 0 && (input.isActive ?? (existing?.amount === 0 ? true : existing?.isActive) ?? true);
const now = new Date();
const row = existing
? await db
.update(budgetPolicies)
.set({
amount,
reservationCents: normalizeCents(input.reservationCents ?? existing.reservationCents ?? 0),
warnPercent: input.warnPercent ?? existing.warnPercent,
hardStopEnabled: input.hardStopEnabled ?? existing.hardStopEnabled,
notifyEnabled: input.notifyEnabled ?? existing.notifyEnabled,
unpricedUsagePolicy: input.unpricedUsagePolicy ?? existing.unpricedUsagePolicy,
isActive: nextIsActive,
updatedByUserId: actorUserId,
updatedAt: now,
@@ -559,9 +697,11 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
metric,
windowKind,
amount,
reservationCents: normalizeCents(input.reservationCents ?? 0),
warnPercent: input.warnPercent ?? 80,
hardStopEnabled: input.hardStopEnabled ?? true,
notifyEnabled: input.notifyEnabled ?? true,
unpricedUsagePolicy: input.unpricedUsagePolicy ?? "block",
isActive: nextIsActive,
createdByUserId: actorUserId,
updatedByUserId: actorUserId,
@@ -589,28 +729,33 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
.where(eq(agents.id, input.scopeId));
}
if (amount > 0) {
const observedAmount = await computeObservedAmount(db, row);
if (observedAmount < amount) {
if (row.isActive && amount > 0) {
const observed = await computeObservedSpend(db, row);
if (!observedBlocks(row, observed)) {
await resumeScopeFromBudget(row);
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
await resolveOpenIncidentsForPolicy(row.id, "approved", actorUserId ?? "budget_service", "hard");
if (row.notifyEnabled && compareCents(observed.totalExact, Math.ceil(row.amount * row.warnPercent / 100)) >= 0) {
await createIncidentIfNeeded(row, "soft", observed);
} else {
await resolveOpenIncidentsForPolicy(row.id, null, null, "soft");
}
} else {
const softThreshold = Math.ceil((row.amount * row.warnPercent) / 100);
if (row.notifyEnabled && observedAmount >= softThreshold) {
await createIncidentIfNeeded(row, "soft", observedAmount);
if (row.notifyEnabled && compareCents(observed.totalExact, softThreshold) >= 0 && (!row.hardStopEnabled || compareCents(observed.totalExact, row.amount) < 0)) {
await createIncidentIfNeeded(row, "soft", observed);
}
if (row.hardStopEnabled && observedAmount >= row.amount) {
if (observedBlocks(row, observed)) {
await resolveOpenSoftIncidents(row.id);
await createIncidentIfNeeded(row, "hard", observedAmount);
await createIncidentIfNeeded(row, "hard", observed);
await pauseAndCancelScopeForBudget(row);
}
}
} else {
await resumeScopeFromBudget(row);
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
await resolveOpenIncidentsForPolicy(row.id, "approved", actorUserId ?? "budget_service");
}
await logActivity(db, {
await recordActivity({
companyId,
actorType: "user",
actorId: actorUserId ?? "board",
@@ -630,7 +775,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
overview: async (companyId: string): Promise<BudgetOverview> => {
const rows = await listPolicyRows(companyId);
const policies = await Promise.all(rows.map((row) => buildPolicySummary(row)));
const policies = await mapBudgetReads(rows, buildPolicySummary);
const activeIncidentRows = await db
.select()
.from(budgetIncidents)
@@ -641,8 +786,8 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
companyId,
policies,
activeIncidents,
pausedAgentCount: policies.filter((policy) => policy.scopeType === "agent" && policy.paused).length,
pausedProjectCount: policies.filter((policy) => policy.scopeType === "project" && policy.paused).length,
pausedAgentCount: new Set(policies.filter((policy) => policy.scopeType === "agent" && policy.paused).map((policy) => policy.scopeId)).size,
pausedProjectCount: new Set(policies.filter((policy) => policy.scopeType === "project" && policy.paused).map((policy) => policy.scopeId)).size,
pendingApprovalCount: activeIncidents.filter((incident) => incident.approvalStatus === "pending").length,
};
},
@@ -655,26 +800,21 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
and(
eq(budgetPolicies.companyId, event.companyId),
eq(budgetPolicies.isActive, true),
inArray(budgetPolicies.scopeType, ["company", "agent", "project"]),
budgetPoliciesForRun(event.companyId, event.agentId, event.projectId),
),
);
const relevantPolicies = candidatePolicies.filter((policy) => {
if (policy.scopeType === "company") return policy.scopeId === event.companyId;
if (policy.scopeType === "agent") return policy.scopeId === event.agentId;
if (policy.scopeType === "project") return Boolean(event.projectId) && policy.scopeId === event.projectId;
return false;
});
for (const policy of relevantPolicies) {
if (policy.metric !== "billed_cents" || policy.amount <= 0) continue;
const observedAmount = await computeObservedAmount(db, policy);
const policies = candidatePolicies.filter(policy => policy.metric === "billed_cents" && policy.amount > 0);
const observations = await computeObservedSpends(db, policies);
for (const [index, policy] of policies.entries()) {
const observed = observations[index];
const observedAmount = observed.total;
const softThreshold = Math.ceil((policy.amount * policy.warnPercent) / 100);
if (policy.notifyEnabled && observedAmount >= softThreshold) {
const softIncident = await createIncidentIfNeeded(policy, "soft", observedAmount);
if (policy.notifyEnabled && compareCents(observed.totalExact, softThreshold) >= 0 && (!policy.hardStopEnabled || compareCents(observed.totalExact, policy.amount) < 0)) {
const softIncident = await createIncidentIfNeeded(policy, "soft", observed);
if (softIncident?.created) {
await logActivity(db, {
await recordActivity({
companyId: policy.companyId,
actorType: "system",
actorId: "budget_service",
@@ -691,12 +831,12 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
}
}
if (policy.hardStopEnabled && observedAmount >= policy.amount) {
if (observedBlocks(policy, observed)) {
await resolveOpenSoftIncidents(policy.id);
const hardIncident = await createIncidentIfNeeded(policy, "hard", observedAmount);
const hardIncident = await createIncidentIfNeeded(policy, "hard", observed);
await pauseAndCancelScopeForBudget(policy);
if (hardIncident?.created) {
await logActivity(db, {
await recordActivity({
companyId: policy.companyId,
actorType: "system",
actorId: "budget_service",
@@ -721,147 +861,18 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
agentId: string,
context?: { issueId?: string | null; projectId?: string | null },
) => {
const agent = await db
.select({
status: agents.status,
pauseReason: agents.pauseReason,
companyId: agents.companyId,
name: agents.name,
})
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
if (!agent || agent.companyId !== companyId) throw notFound("Agent not found");
const company = await db
.select({
status: companies.status,
pauseReason: companies.pauseReason,
name: companies.name,
})
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
if (company.status === "paused") {
return {
scopeType: "company" as const,
scopeId: companyId,
scopeName: company.name,
reason:
company.pauseReason === "budget"
? "Company is paused because its budget hard-stop was reached."
: "Company is paused and cannot start new work.",
};
const scopes: Array<{ scopeType: BudgetScopeType; scopeId: string }> = [
{ scopeType: "company", scopeId: companyId }, { scopeType: "agent", scopeId: agentId },
...(context?.projectId ? [{ scopeType: "project" as const, scopeId: context.projectId }] : []),
];
for (const { scopeType, scopeId } of scopes) {
if ((await resolveScopeRecord(db, scopeType, scopeId)).companyId !== companyId) throw notFound("Budget scope not found");
}
const companyPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "company"),
eq(budgetPolicies.scopeId, companyId),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (companyPolicy && companyPolicy.hardStopEnabled && companyPolicy.amount > 0) {
const observed = await computeObservedAmount(db, companyPolicy);
if (observed >= companyPolicy.amount) {
return {
scopeType: "company" as const,
scopeId: companyId,
scopeName: company.name,
reason: "Company cannot start new work because its budget hard-stop is exceeded.",
};
}
for (const { scopeType, scopeId } of scopes) {
const block = await reconcileScope(companyId, scopeType, scopeId);
if (block) return block;
}
if (agent.status === "paused" && agent.pauseReason === "budget") {
return {
scopeType: "agent" as const,
scopeId: agentId,
scopeName: agent.name,
reason: "Agent is paused because its budget hard-stop was reached.",
};
}
const agentPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "agent"),
eq(budgetPolicies.scopeId, agentId),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (agentPolicy && agentPolicy.hardStopEnabled && agentPolicy.amount > 0) {
const observed = await computeObservedAmount(db, agentPolicy);
if (observed >= agentPolicy.amount) {
return {
scopeType: "agent" as const,
scopeId: agentId,
scopeName: agent.name,
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
};
}
}
const candidateProjectId = context?.projectId ?? null;
if (!candidateProjectId) return null;
const project = await db
.select({
id: projects.id,
name: projects.name,
companyId: projects.companyId,
pauseReason: projects.pauseReason,
pausedAt: projects.pausedAt,
})
.from(projects)
.where(eq(projects.id, candidateProjectId))
.then((rows) => rows[0] ?? null);
if (!project || project.companyId !== companyId) return null;
const projectPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "project"),
eq(budgetPolicies.scopeId, project.id),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (projectPolicy && projectPolicy.hardStopEnabled && projectPolicy.amount > 0) {
const observed = await computeObservedAmount(db, projectPolicy);
if (observed >= projectPolicy.amount) {
return {
scopeType: "project" as const,
scopeId: project.id,
scopeName: project.name,
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
};
}
}
if (!project.pausedAt || project.pauseReason !== "budget") return null;
return {
scopeType: "project" as const,
scopeId: project.id,
scopeName: project.name,
reason: "Project is paused because its budget hard-stop was reached.",
};
return null;
},
resolveIncident: async (
@@ -881,8 +892,12 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
const policy = await getPolicyRow(incident.policyId);
if (input.action === "raise_budget_and_resume") {
const nextAmount = Math.max(0, Math.floor(input.amount ?? 0));
const currentObserved = await computeObservedAmount(db, policy);
if (nextAmount <= currentObserved) {
const observed = await computeObservedSpend(db, policy);
if (policy.unpricedUsagePolicy !== "allow" && observed.unpricedEventCount > 0) {
throw unprocessable("Resolve unpriced usage or explicitly allow it in the budget policy before resuming");
}
if (observed.pendingRunCount > 0) throw unprocessable("Completed runs must finish accounting before resuming");
if (compareCents(observed.totalExact, nextAmount) >= 0) {
throw unprocessable("New budget must exceed current observed spend");
}
@@ -934,7 +949,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
await markApprovalStatus(db, incident.approvalId ?? null, "rejected", input.decisionNote, actorUserId);
}
await logActivity(db, {
await recordActivity({
companyId: incident.companyId,
actorType: "user",
actorId: actorUserId,
@@ -959,3 +974,78 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
},
};
}
/** Fence the durable stop intent against policy edits and admission. External
* process shutdown must happen after this transaction, without holding locks
* that the executor needs to persist its final usage receipt. */
export async function withCurrentBudgetEnforcement<T>(db: Db, scope: BudgetEnforcementScope, work: (tx: Db) => Promise<T>): Promise<T | null> {
return withAccountingTransaction(db, scope.companyId, async (tx) => {
if (scope.enforcement) {
const [policy] = await tx.select().from(budgetPolicies).where(and(
eq(budgetPolicies.id, scope.enforcement.policyId), eq(budgetPolicies.companyId, scope.companyId),
));
if (!policy || policy.scopeType !== scope.scopeType || policy.scopeId !== scope.scopeId
|| policy.enforcementVersion !== scope.enforcement.version || !(await policyBlocks(tx, policy))) return null;
}
return work(tx);
});
}
/** Cancellation is an at-least-once external effect. A failed delivery never
* rolls back committed spend, and its version remains pending for recovery. */
export async function deliverBudgetEnforcement(db: Db, hooks: BudgetServiceHooks, companyId?: string) {
if (!hooks.cancelWorkForScope) return;
const pending = await db.select().from(budgetPolicies).where(and(
sql`${budgetPolicies.enforcementVersion} > ${budgetPolicies.enforcementDeliveredVersion}`,
companyId ? eq(budgetPolicies.companyId, companyId) : undefined,
));
for (const policy of pending) {
try {
const cancellation = await withAccountingTransaction(db, policy.companyId, async (tx) => {
const [current] = await tx.select().from(budgetPolicies).where(eq(budgetPolicies.id, policy.id));
if (!current || current.enforcementVersion !== policy.enforcementVersion || !(await policyBlocks(tx, current))) return null;
return { companyId: policy.companyId, scopeType: policy.scopeType as BudgetScopeType, scopeId: policy.scopeId, createdBefore: new Date(), enforcement: { policyId: policy.id, version: policy.enforcementVersion } };
});
if (cancellation) await hooks.cancelWorkForScope(cancellation);
await db.update(budgetPolicies).set({ enforcementDeliveredVersion: policy.enforcementVersion })
.where(and(eq(budgetPolicies.id, policy.id), eq(budgetPolicies.enforcementVersion, policy.enforcementVersion)));
} catch (error) {
logger.warn({ err: error, policyId: policy.id }, "Budget cancellation delivery pending; accounting is committed");
}
}
}
export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
const reads = budgetServiceInTransaction(db);
async function mutate<T>(companyId: string, work: (service: ReturnType<typeof budgetServiceInTransaction>) => Promise<T>) {
const result = await withAccountingTransaction(db, companyId, (tx, publications) => work(budgetServiceInTransaction(tx, publications)));
await deliverBudgetEnforcement(db, hooks, companyId);
return result;
}
return {
...reads,
deliverPendingEnforcement: (companyId?: string) => deliverBudgetEnforcement(db, hooks, companyId),
reconcilePolicies: async () => {
const scopes = await db.selectDistinct({ companyId: budgetPolicies.companyId, scopeType: budgetPolicies.scopeType, scopeId: budgetPolicies.scopeId })
.from(budgetPolicies).orderBy(budgetPolicies.companyId, budgetPolicies.scopeType, budgetPolicies.scopeId);
// Each scope commits independently: deleted targets and a failed scope
// must not roll back recovery or cancellation delivery for other work.
for (const scope of scopes) {
try {
await mutate(scope.companyId, (service) => service.reconcileScope(scope.companyId, scope.scopeType as BudgetScopeType, scope.scopeId));
} catch (error) {
if (error instanceof HttpError && error.status === 404) continue;
logger.warn({ err: error, ...scope }, "Budget scope recovery pending; continuing remaining scopes");
}
}
},
upsertPolicy: (companyId: string, input: BudgetPolicyUpsertInput, actorUserId: string | null) =>
mutate(companyId, (service) => service.upsertPolicy(companyId, input, actorUserId)),
evaluateCostEvent: (event: typeof costEvents.$inferSelect) =>
mutate(event.companyId, (service) => service.evaluateCostEvent(event)),
getInvocationBlock: (companyId: string, agentId: string, context?: { issueId?: string | null; projectId?: string | null }) =>
mutate(companyId, (service) => service.getInvocationBlock(companyId, agentId, context)),
resolveIncident: (companyId: string, incidentId: string, input: BudgetIncidentResolutionInput, actorUserId: string) =>
mutate(companyId, (service) => service.resolveIncident(companyId, incidentId, input, actorUserId)),
};
}
+98
View File
@@ -0,0 +1,98 @@
import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared";
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
// Immutable, reviewed rate card. Existing receipts retain their selected rates.
// https://developers.openai.com/api/docs/pricing (retrieved 2026-09-30)
const VERSION = "openai-standard-2026-09-30";
const RATES: Record<string, readonly [string, string, string, string]> = {
"gpt-6-astra": ["10", "1", "12.5", "50"],
"gpt-6-sol": ["2", "0.2", "2.5", "10"],
"gpt-6.1-sol": ["2", "0.1", "2.5", "10"],
"gpt-6-luna": ["0.1", "0.01", "0.125", "0.5"],
"gpt-5.6-sol": ["4", "0.4", "5", "20"],
};
/** Price new, direct-API receipts only. A token-price estimate is not an invoice.
* Run totals cannot establish per-request context length or cache-write splits;
* preserve those assumptions instead of inferring a long request from a long run. */
export function priceCodexReceipt(
receipt: AdapterUsageCheckpoint,
): AdapterUsageCheckpoint {
if (
!receipt.complete ||
receipt.costUsd != null ||
receipt.costUsdExact != null ||
receipt.cacheAdjustedCostUsd != null ||
receipt.provider !== "openai" ||
receipt.biller !== "openai" ||
!["api", "metered_api"].includes(receipt.billingType ?? "") ||
receipt.usageBasis !== "per_run" ||
!receipt.usage ||
receipt.usageByModel?.length
)
return receipt;
const model = receipt.model ?? "";
const rates = Object.hasOwn(RATES, model) ? RATES[model] : undefined;
if (!rates) return receipt;
const usage = receipt.usage;
const counts = [
usage.inputTokens - (usage.cacheWriteTokens ?? 0),
usage.cachedInputTokens ?? 0,
usage.cacheWriteTokens ?? 0,
usage.outputTokens,
];
if (counts.some((n) => !Number.isSafeInteger(n) || n < 0)) return receipt;
const context = receipt.pricingContext;
if (
context?.serviceTier &&
!["standard", "default", "priority", "fast", "flex", "batch"].includes(
context.serviceTier,
)
)
return receipt;
const tierNumerator =
context?.serviceTier === "priority" || context?.serviceTier === "fast"
? 2n
: 1n;
const tierDenominator =
context?.serviceTier === "flex" || context?.serviceTier === "batch"
? 2n
: 1n;
const units = counts.reduce((total, count, index) => {
const longNumerator =
context?.contextTier === "long" ? (index === 3 ? 3n : 4n) : 2n;
return (
total +
BigInt(count) * usdToUnits(rates[index]) * tierNumerator * longNumerator
);
}, 0n);
const divisor = 1_000_000n * tierDenominator * 2n;
const costCents = unitsToCents((units + divisor / 2n) / divisor);
const assumptions = [
!context?.serviceTier
? "standard processing assumed"
: `${context.serviceTier} processing`,
!context?.contextTier
? "short per-request context assumed"
: `${context.contextTier} per-request context`,
usage.cacheWriteTokens === undefined
? "cache writes not separately reported"
: "cache writes reported",
];
return {
...receipt,
costUsdExact: centsToUsd(costCents),
costStatus: "estimated",
pricingProvenance: {
source: "rate_card",
version: VERSION,
evidence: `https://developers.openai.com/api/docs/pricing; ${assumptions.join("; ")}`,
inputCentsPerMillion: unitsToCents(usdToUnits(rates[0])),
cachedInputCentsPerMillion: unitsToCents(usdToUnits(rates[1])),
cacheWriteCentsPerMillion: unitsToCents(usdToUnits(rates[2])),
outputCentsPerMillion: unitsToCents(usdToUnits(rates[3])),
serviceTier: context?.serviceTier ?? "standard",
contextTier: context?.contextTier ?? "short",
},
};
}
+31 -4
View File
@@ -1,3 +1,5 @@
import { publishAccountingActivities } from "./accounting-transaction.js";
import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
import { and, count, eq, gte, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -18,6 +20,8 @@ import {
heartbeatRunEvents,
costEvents,
financeEvents,
budgetPolicies,
budgetIncidents,
issueReadStates,
approvalComments,
approvals,
@@ -47,7 +51,7 @@ import {
} from "./issue-prefix.js";
import { environmentService } from "./environments.js";
import { heartbeatService } from "./heartbeat.js";
import { logActivity } from "./activity-log.js";
import { logActivity, type ActivityPublication } from "./activity-log.js";
import { builtInAgentService } from "./built-in-agents.js";
@@ -66,7 +70,7 @@ const SYSTEM_COMPANY_ACTOR: CompanyActivityActor = {
runId: null,
};
export function companyService(db: Db) {
export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
const environmentsSvc = environmentService(db);
const heartbeat = heartbeatService(db);
const builtInAgents = builtInAgentService(db);
@@ -332,12 +336,16 @@ export function companyService(db: Db) {
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR,
) => {
const budgetPublications: ActivityPublication[] = [];
const result = await db.transaction(async (tx) => {
const existing = await getCompanyQuery(tx)
.where(eq(companies.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (data.budgetMonthlyCents !== undefined) {
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, id)).for("no key update");
}
const { logoAssetId, ...companyPatch } = data;
const willReactivate = existing.status !== "active" && companyPatch.status === "active";
const willArchive = existing.status !== "archived" && companyPatch.status === "archived";
@@ -431,6 +439,13 @@ export function companyService(db: Db) {
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
}
if (data.budgetMonthlyCents !== undefined) {
await budgetServiceInTransaction(tx as unknown as Db, budgetPublications).upsertPolicy(id, {
scopeType: "company", scopeId: id, amount: data.budgetMonthlyCents, isActive: data.budgetMonthlyCents > 0, windowKind: "calendar_month_utc",
}, actor.actorType === "user" ? actor.actorId : null);
const [budgetUpdated] = await tx.select().from(companies).where(eq(companies.id, id));
Object.assign(updated, budgetUpdated);
}
const [hydrated] = await hydrateCompanySpend([{
...updated,
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
@@ -448,6 +463,8 @@ export function companyService(db: Db) {
};
});
if (!result) return null;
publishAccountingActivities(id, budgetPublications);
if (data.budgetMonthlyCents !== undefined) await deliverBudgetEnforcement(db, budgetHooks, id);
// Post-commit, fire-and-forget, and BEFORE any finalization that
// could throw: a Cloud-pinned primary company that crossed the
// archived boundary (either direction) rings the harness so the
@@ -537,6 +554,18 @@ export function companyService(db: Db) {
remove: (id: string) =>
db.transaction(async (tx) => {
// Exclude accounting writers before taking child locks. KEY SHARE must
// remain compatible: native writers can already hold a child row while
// saving a company-scoped result. FOR UPDATE would deadlock that save.
const [existing] = await tx.select({ id: companies.id }).from(companies)
.where(eq(companies.id, id)).for("no key update");
if (!existing) return null;
// Finance can reference costs and both can reference runs. Incidents
// reference policies and approvals; delete these dependents first.
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
await tx.delete(budgetIncidents).where(eq(budgetIncidents.companyId, id));
await tx.delete(budgetPolicies).where(eq(budgetPolicies.companyId, id));
// Delete from child tables in dependency order
const companyRunIds = await tx
.select({ id: heartbeatRuns.id })
@@ -557,8 +586,6 @@ export function companyService(db: Db) {
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await tx.delete(approvals).where(eq(approvals.companyId, id));
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
+1 -1
View File
@@ -6623,7 +6623,7 @@ export function companySkillService(db: Db) {
const rows = await db
.select({
issueId: costEvents.issueId,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
+12
View File
@@ -0,0 +1,12 @@
export interface CostDateRange {
from?: Date;
to?: Date;
allTime?: boolean;
}
/** Default reports match the UTC month used by monetary budgets. Explicit
* bounds preserve the existing inclusive API; all-time must be requested. */
export function resolveCostDateRange(range?: CostDateRange, now = new Date()): CostDateRange {
if (range?.allTime || range?.from || range?.to) return range;
return { from: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)), to: now };
}
+247 -79
View File
@@ -1,21 +1,32 @@
import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
import { receiptFingerprint } from "./receipt-fingerprint.js";
import { agentAvatarUrl, resolveAgentAppearance, createCostEventSchema, normalizeCents, type MoneyInput, type CostByUserReport } from "@paperclipai/shared";
import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, ne, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
import { activityLog, agents, agentRuntimeState, authUsers, companies, companyMemberships, costEvents, heartbeatRuns, issues, projects, goals } from "@paperclipai/db";
import { notFound, unprocessable, conflict } from "../errors.js";
import { budgetService, budgetServiceInTransaction, type BudgetServiceHooks } from "./budgets.js";
import { logActivity, type LogActivityInput, type ActivityPublication } from "./activity-log.js";
import { withAccountingReadSnapshot, withAccountingTransaction } from "./accounting-transaction.js";
import { visibleIssueCondition } from "./issue-visibility.js";
export interface CostDateRange {
from?: Date;
to?: Date;
}
import { resolveCostDateRange, type CostDateRange } from "./cost-date-range.js";
export type { CostDateRange } from "./cost-date-range.js";
const METERED_BILLING_TYPE = "metered_api";
const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const;
function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) {
// Pre-receipt Codex events stored cache reads inside input; Pi/OpenCode used
// provider-qualified model IDs and already excluded cache reads. Normalize each
// historical row before aggregation so mixed old/new groups share the current
// exclusive-input contract. Preserve the original ledger and monetary amounts.
const ordinaryInputTokens = sql<number>`case
when ${costEvents.receiptHash} is null and ${costEvents.provider} = 'openai'
and ${costEvents.model} not like 'openai/_%'
then greatest(0, ${costEvents.inputTokens} - ${costEvents.cachedInputTokens})
else ${costEvents.inputTokens} end`;
function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens | ReturnType<typeof sql>) {
return sql<number>`coalesce(sum(${column}), 0)::double precision`;
}
@@ -28,6 +39,26 @@ function currentUtcMonthWindow(now = new Date()) {
};
}
/** Increment an initialized month under the company lock. On upgrade or UTC
* rollover, initialize from the ledger in the same transaction exactly once.
* Reports, budgets and the integrity checker continue reading the ledger. */
export async function updateMonthlySpendProjections(db: Db, companyId: string, agentId: string, amountCents: MoneyInput, occurredAt: Date) {
const { start, end } = currentUtcMonthWindow();
const month = start.toISOString().slice(0, 10);
const delta = occurredAt >= start && occurredAt < end ? normalizeCents(amountCents) : "0.0000000";
for (const table of [companies, agents] as const) {
const id = table === companies ? companyId : agentId;
const companyCondition = table === companies ? eq(companies.id, companyId) : eq(agents.companyId, companyId);
await db.update(table).set({
spentMonthlyCents: sql`case when ${table.spendMonthUtc} = ${month} then ${table.spentMonthlyCents} + ${delta}::numeric else (
select coalesce(sum(${costEvents.costCents}),0) from ${costEvents} where ${costEvents.companyId} = ${companyId}
and ${costEvents.occurredAt} >= ${start.toISOString()}::timestamptz and ${costEvents.occurredAt} < ${end.toISOString()}::timestamptz
${table === agents ? sql`and ${costEvents.agentId} = ${agentId}` : sql``}) end`,
spendMonthUtc: month, updatedAt: new Date(),
}).where(and(eq(table.id, id), companyCondition));
}
}
export async function getMonthlySpendTotal(
db: Db,
scope: { companyId: string; agentId?: string | null },
@@ -50,60 +81,88 @@ export async function getMonthlySpendTotal(
return Number(row?.total ?? 0);
}
export async function createCostEventInTransaction(db: Db, companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId" | "receiptHash" | "costCents"> & { costCents: MoneyInput }, publications: ActivityPublication[] = [], actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId">) {
const parsed = createCostEventSchema.safeParse({ ...data, occurredAt: data.occurredAt.toISOString() });
if (!parsed.success) throw unprocessable("Invalid cost receipt", parsed.error.flatten());
const values = {
...parsed.data,
issueId: parsed.data.issueId ?? null,
projectId: parsed.data.projectId ?? null,
goalId: parsed.data.goalId ?? null,
heartbeatRunId: parsed.data.heartbeatRunId ?? null,
billingCode: parsed.data.billingCode ?? null,
idempotencyKey: parsed.data.idempotencyKey ?? null,
costCents: normalizeCents(parsed.data.costCents),
occurredAt: new Date(parsed.data.occurredAt),
};
const receiptHash = receiptFingerprint(values);
// Older receipts hashed the numeric API value. Retain replay compatibility
// only when the decimal value survives that representation exactly.
let legacyHash: string | null = null;
try {
if (normalizeCents(Number(values.costCents)) === values.costCents) legacyHash = receiptFingerprint({ ...values, costCents: Number(values.costCents) });
} catch { /* The legacy number may round beyond the decimal storage limit. */ }
if (values.idempotencyKey) {
const [existing] = await db.select().from(costEvents).where(and(
eq(costEvents.companyId, companyId), eq(costEvents.idempotencyKey, values.idempotencyKey),
));
if (existing) {
if (existing.receiptHash !== receiptHash && existing.receiptHash !== legacyHash) throw conflict("Idempotency key already used for a different cost receipt");
const [exact] = await db.select({ costCents: sql<string>`${costEvents.costCents}::text` }).from(costEvents).where(eq(costEvents.id, existing.id));
return { ...existing, costCentsExact: normalizeCents(exact.costCents) };
}
}
const links = [
[agents, values.agentId, "Agent"], [issues, values.issueId, "Issue"],
[projects, values.projectId, "Project"], [goals, values.goalId, "Goal"],
[heartbeatRuns, values.heartbeatRunId, "Heartbeat run"],
] as const;
for (const [table, id, label] of links) {
if (!id) continue;
const [row] = await db.select({ companyId: table.companyId }).from(table).where(eq(table.id, id));
if (!row) throw notFound(`${label} not found`);
if (row.companyId !== companyId) throw unprocessable(`${label} does not belong to company`);
}
if (values.heartbeatRunId) {
const [run] = await db.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, values.heartbeatRunId));
if (run.agentId !== values.agentId) throw unprocessable("Heartbeat run does not belong to agent");
}
const [event] = await db.insert(costEvents).values({ ...values, costCents: sql`${values.costCents}::numeric`, reportedCostCents: values.costCents, id: data.id, companyId, receiptHash }).returning();
await updateMonthlySpendProjections(db, companyId, event.agentId, values.costCents, event.occurredAt);
// Separately reported charges linked to an already-accounted run contribute
// to lifetime totals too. Before acknowledgement, accountRunCost includes
// all of the run's events atomically when it initializes the projection.
if (event.heartbeatRunId) {
const [run] = await db.select({ acknowledged: heartbeatRuns.costAccountedAt, version: heartbeatRuns.accountingProjectionVersion })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, event.heartbeatRunId), eq(heartbeatRuns.companyId, companyId)));
if (run.acknowledged && run.version === "v2") await db.update(agentRuntimeState).set({
totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${values.costCents}::numeric`,
totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${event.inputTokens}`,
totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${event.cachedInputTokens}`,
totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${event.outputTokens}`,
updatedAt: new Date(),
}).where(and(eq(agentRuntimeState.agentId, event.agentId), eq(agentRuntimeState.companyId, companyId)));
}
await budgetServiceInTransaction(db, publications).evaluateCostEvent(event);
await logActivity(db, {
companyId, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? "cost_accounting", agentId: actor?.agentId ?? event.agentId,
runId: event.heartbeatRunId, action: "cost.reported", entityType: "cost_event", entityId: event.id,
details: { costCents: event.costCents, costCentsExact: values.costCents, model: event.model, costStatus: event.costStatus },
}, publications);
return { ...event, costCentsExact: values.costCents };
}
export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
const budgets = budgetService(db, budgetHooks);
return {
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
const agent = await db
.select()
.from(agents)
.where(eq(agents.id, data.agentId))
.then((rows) => rows[0] ?? null);
if (!agent) throw notFound("Agent not found");
if (agent.companyId !== companyId) {
throw unprocessable("Agent does not belong to company");
}
const event = await db
.insert(costEvents)
.values({
...data,
companyId,
biller: data.biller ?? data.provider,
billingType: data.billingType ?? "unknown",
cachedInputTokens: data.cachedInputTokens ?? 0,
})
.returning()
.then((rows) => rows[0]);
const [agentMonthSpend, companyMonthSpend] = await Promise.all([
getMonthlySpendTotal(db, { companyId, agentId: event.agentId }),
getMonthlySpendTotal(db, { companyId }),
]);
await db
.update(agents)
.set({
spentMonthlyCents: agentMonthSpend,
updatedAt: new Date(),
})
.where(eq(agents.id, event.agentId));
await db
.update(companies)
.set({
spentMonthlyCents: companyMonthSpend,
updatedAt: new Date(),
})
.where(eq(companies.id, companyId));
await budgets.evaluateCostEvent(event);
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId" | "receiptHash" | "costCents"> & { costCents: MoneyInput }, actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId">) => {
if (data.idempotencyKey?.startsWith("heartbeat:")) throw unprocessable("The heartbeat idempotency namespace is reserved for run receipts");
const event = await withAccountingTransaction(db, companyId, (tx, publications) => createCostEventInTransaction(tx, companyId, data, publications, actor));
await budgets.deliverPendingEnforcement(companyId);
return event;
},
summary: async (companyId: string, range?: CostDateRange) => {
summary: async (companyId: string, range?: CostDateRange) => withAccountingReadSnapshot(db, companyId, async (db) => {
const company = await db
.select()
.from(companies)
@@ -112,17 +171,28 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
if (!company) throw notFound("Company not found");
range = resolveCostDateRange(range);
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
const [{ total }] = await db
const [{ total, totalExact, eventCount, unpricedEventCount, estimatedEventCount }] = await db
.select({
eventCount: sql<number>`count(*)::int`,
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
unpricedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`,
total: sumAsNumber(costEvents.costCents),
totalExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
})
.from(costEvents)
.where(and(...conditions));
const pendingConditions = [eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.costAccountingPending, true),
inArray(heartbeatRuns.status, ["succeeded", "failed", "timed_out", "cancelled", "interrupted"])];
if (range?.from) pendingConditions.push(sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${range.from.toISOString()}::timestamptz`);
if (range?.to) pendingConditions.push(sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) <= ${range.to.toISOString()}::timestamptz`);
const [pending] = await db.select({ count: sql<number>`count(*)::int` }).from(heartbeatRuns).where(and(...pendingConditions));
const pendingRunCount = pending?.count ?? 0;
const spendCents = Number(total);
const utilization =
company.budgetMonthlyCents > 0
@@ -131,11 +201,17 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
return {
companyId,
eventCount,
unpricedEventCount,
estimatedEventCount,
pendingRunCount,
pricingComplete: unpricedEventCount === 0 && pendingRunCount === 0,
spendCents,
spendCentsExact: normalizeCents(totalExact),
budgetCents: company.budgetMonthlyCents,
utilizationPercent: Number(utilization.toFixed(2)),
};
},
}),
issueTreeSummary: async (
companyId: string,
@@ -238,7 +314,8 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
.select({
issueCount: sql<number>`count(distinct ${issues.id})::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
@@ -270,6 +347,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
issueCount: Number(costRow?.issueCount ?? 0),
includeDescendants: true,
costCents: Number(costRow?.costCents ?? 0),
costCentsExact: normalizeCents(costRow?.costCentsExact ?? 0),
inputTokens: Number(costRow?.inputTokens ?? 0),
cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0),
outputTokens: Number(costRow?.outputTokens ?? 0),
@@ -278,7 +356,79 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
};
},
byUser: (companyId: string, range?: CostDateRange): Promise<CostByUserReport> => withAccountingReadSnapshot(db, companyId, async tx => {
// Keep membership, identity and spend in one non-blocking read snapshot.
const members = await tx.select({
userId: companyMemberships.principalId,
status: companyMemberships.status,
userName: authUsers.name,
userImage: authUsers.image,
}).from(companyMemberships)
.leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
.where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
ne(companyMemberships.principalId, "local-board"),
));
const activeMembers = members.filter(member => member.status === "active");
const activeUserCount = activeMembers.length;
const bounds = resolveCostDateRange(range);
const conditions = [eq(costEvents.companyId, companyId)];
if (bounds.from) conditions.push(gte(costEvents.occurredAt, bounds.from));
if (bounds.to) conditions.push(lte(costEvents.occurredAt, bounds.to));
const totals = await tx.select({
userId: companyMemberships.principalId,
eventCount: sql<number>`count(*)::int`,
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
unpricedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`,
costCents: sumAsNumber(costEvents.costCents),
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
runCount: sql<number>`count(distinct ${heartbeatRuns.id})::int`,
}).from(costEvents)
.leftJoin(heartbeatRuns, and(
eq(heartbeatRuns.id, costEvents.heartbeatRunId),
eq(heartbeatRuns.companyId, costEvents.companyId),
eq(heartbeatRuns.agentId, costEvents.agentId),
))
// Never resolve names through a foreign-company run or membership. Retain
// former members' historical spend; missing attribution remains in the total.
// The synthetic Board principal is not a person. Any historical charges
// attached to it remain in Unattributed so the report still reconciles.
.leftJoin(companyMemberships, and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
ne(companyMemberships.principalId, "local-board"),
eq(companyMemberships.principalId, heartbeatRuns.responsibleUserId),
))
.where(and(...conditions))
.groupBy(companyMemberships.principalId)
.orderBy(desc(sql`sum(${costEvents.costCents})`), companyMemberships.principalId);
const directory = new Map(members.map(member => [member.userId, member]));
const rows: CostByUserReport["rows"] = totals.map(row => ({
...row,
userName: row.userId ? directory.get(row.userId)?.userName ?? "Former user" : null,
userImage: row.userId ? directory.get(row.userId)?.userImage ?? null : null,
costCentsExact: normalizeCents(row.costCentsExact),
}));
const represented = new Set(rows.map(row => row.userId));
for (const member of activeMembers.sort((a, b) => (a.userName ?? a.userId).localeCompare(b.userName ?? b.userId))) {
if (represented.has(member.userId)) continue;
rows.push({
userId: member.userId, userName: member.userName ?? "Unknown user", userImage: member.userImage,
eventCount: 0, estimatedEventCount: 0, unpricedEventCount: 0,
costCents: 0, costCentsExact: "0.0000000",
inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, runCount: 0,
});
}
return { activeUserCount, rows };
}),
byAgent: async (companyId: string, range?: CostDateRange) => {
range = resolveCostDateRange(range);
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
@@ -289,8 +439,11 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
agentName: agents.name,
agentAppearance: agents.appearance,
agentStatus: agents.status,
eventCount: sql<number>`count(*)::int`,
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
apiRunCount:
@@ -300,7 +453,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${ordinaryInputTokens} else 0 end), 0)::double precision`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
})
@@ -316,6 +469,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
},
byProvider: async (companyId: string, range?: CostDateRange) => {
range = resolveCostDateRange(range);
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
@@ -327,7 +481,8 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
billingType: costEvents.billingType,
model: costEvents.model,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
apiRunCount:
@@ -337,7 +492,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${ordinaryInputTokens} else 0 end), 0)::double precision`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
})
@@ -348,6 +503,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
},
byBiller: async (companyId: string, range?: CostDateRange) => {
range = resolveCostDateRange(range);
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
@@ -356,7 +512,8 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
.select({
biller: costEvents.biller,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
apiRunCount:
@@ -366,7 +523,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${ordinaryInputTokens} else 0 end), 0)::double precision`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
providerCount: sql<number>`count(distinct ${costEvents.provider})::int`,
@@ -390,15 +547,17 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
{ label: "7d", hours: 168 },
] as const;
const now = new Date();
const results = await Promise.all(
windows.map(async ({ label, hours }) => {
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
const since = new Date(now.getTime() - hours * 60 * 60 * 1000);
const rows = await db
.select({
provider: costEvents.provider,
biller: sql<string>`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
@@ -407,6 +566,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
and(
eq(costEvents.companyId, companyId),
gte(costEvents.occurredAt, since),
lte(costEvents.occurredAt, now),
),
)
.groupBy(costEvents.provider)
@@ -418,6 +578,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
window: label as string,
windowHours: hours,
costCents: row.costCents,
costCentsExact: normalizeCents(row.costCentsExact),
inputTokens: row.inputTokens,
cachedInputTokens: row.cachedInputTokens,
outputTokens: row.outputTokens,
@@ -429,6 +590,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
},
byAgentModel: async (companyId: string, range?: CostDateRange) => {
range = resolveCostDateRange(range);
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
@@ -446,8 +608,11 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
biller: costEvents.biller,
billingType: costEvents.billingType,
model: costEvents.model,
eventCount: sql<number>`count(*)::int`,
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
@@ -473,9 +638,9 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
byProject: async (companyId: string, range?: CostDateRange) => {
const issueIdAsText = sql<string>`${issues.id}::text`;
const runProjectLinks = db
.selectDistinctOn([activityLog.runId, issues.projectId], {
.select({
runId: activityLog.runId,
projectId: issues.projectId,
projectId: sql<string>`min(${issues.projectId}::text)::uuid`.as("linked_project_id"),
})
.from(activityLog)
.innerJoin(
@@ -493,10 +658,12 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
isNotNull(issues.projectId),
),
)
.orderBy(activityLog.runId, issues.projectId, desc(activityLog.createdAt))
.groupBy(activityLog.runId)
.having(sql`count(distinct ${issues.projectId}) = 1`)
.as("run_project_links");
const effectiveProjectId = sql<string | null>`coalesce(${costEvents.projectId}, ${runProjectLinks.projectId})`;
const effectiveProjectId = sql<string | null>`coalesce(${costEvents.projectId}, case when ${costEvents.receiptHash} is null then ${runProjectLinks.projectId} end)`;
range = resolveCostDateRange(range);
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
@@ -505,18 +672,19 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
return db
.select({
projectId: effectiveProjectId,
projectId: projects.id,
projectName: projects.name,
costCents: costCentsExpr,
inputTokens: sumAsNumber(costEvents.inputTokens),
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
.from(costEvents)
.leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId))
.innerJoin(projects, sql`${projects.id} = ${effectiveProjectId}`)
.where(and(...conditions, sql`${effectiveProjectId} is not null`))
.groupBy(effectiveProjectId, projects.name)
.leftJoin(projects, and(eq(projects.companyId, companyId), sql`${projects.id} = ${effectiveProjectId}`))
.where(and(...conditions))
.groupBy(projects.id, projects.name)
.orderBy(desc(costCentsExpr));
},
};
+2 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, gte, sql } from "drizzle-orm";
import { and, eq, gte, lte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, approvals, companies, costEvents, heartbeatRuns, issues } from "@paperclipai/db";
import { notFound } from "../errors.js";
@@ -96,6 +96,7 @@ export function dashboardService(db: Db) {
and(
eq(costEvents.companyId, companyId),
gte(costEvents.occurredAt, monthStart),
lte(costEvents.occurredAt, now),
),
));
+4 -3
View File
@@ -1,7 +1,7 @@
import { HttpError } from "../errors.js";
import { createHash, randomUUID } from "node:crypto";
import WebSocket from "ws";
import { and, asc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
import { and, asc, eq, getTableColumns, inArray, isNull, ne, sql } from "drizzle-orm";
import {
type Db,
agents,
@@ -27,6 +27,7 @@ import {
companyMemberships,
instanceUserRoles,
} from "@paperclipai/db";
import { compareCents } from "@paperclipai/shared";
import type {
AgentPermissions,
EmailEndpointSetupInput,
@@ -522,7 +523,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
);
await authorizeRead(endpoint.companyId, issueId, actor);
const [agent] = await db
.select()
.select({ ...getTableColumns(agents), spentMonthlyCentsExact: sql<string>`${agents.spentMonthlyCents}::text` })
.from(agents)
.where(
and(
@@ -534,7 +535,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
!agent ||
["paused", "terminated", "pending_approval"].includes(agent.status) ||
(agent.budgetMonthlyCents > 0 &&
agent.spentMonthlyCents >= agent.budgetMonthlyCents)
compareCents(agent.spentMonthlyCentsExact, agent.budgetMonthlyCents) >= 0)
)
throw forbidden("Agent is not available to send email");
if (!actor.runId)
+84 -35
View File
@@ -1,12 +1,14 @@
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import { and, desc, eq, getTableColumns, gte, lte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, costEvents, financeEvents, goals, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { notFound, unprocessable, conflict } from "../errors.js";
export interface FinanceDateRange {
from?: Date;
to?: Date;
}
import { resolveCostDateRange, type CostDateRange as FinanceDateRange } from "./cost-date-range.js";
import { createFinanceEventSchema, normalizeCents, subtractCents, type MoneyInput } from "@paperclipai/shared";
import { receiptFingerprint } from "./receipt-fingerprint.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { logActivity, type LogActivityInput, type ActivityPublication } from "./activity-log.js";
export type { CostDateRange as FinanceDateRange } from "./cost-date-range.js";
async function assertBelongsToCompany(
db: Db,
@@ -28,19 +30,29 @@ async function assertBelongsToCompany(
}
function rangeConditions(companyId: string, range?: FinanceDateRange) {
range = resolveCostDateRange(range);
const conditions: ReturnType<typeof eq>[] = [eq(financeEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(financeEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(financeEvents.occurredAt, range.to));
return conditions;
}
export function financeService(db: Db) {
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
return {
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId">) => {
export async function createFinanceEventInTransaction(tx: Db, publications: ActivityPublication[], companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId" | "receiptHash" | "amountCents"> & { amountCents: MoneyInput }, actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId">) {
const db = tx;
const parsed = createFinanceEventSchema.safeParse({ ...data, occurredAt: data.occurredAt.toISOString() });
if (!parsed.success) throw unprocessable("Invalid finance receipt", parsed.error.flatten());
const values = { ...parsed.data, amountCents: normalizeCents(parsed.data.amountCents), occurredAt: new Date(parsed.data.occurredAt) };
const receiptHash = receiptFingerprint(values);
let legacyHash: string | null = null;
try { if (normalizeCents(Number(values.amountCents)) === values.amountCents) legacyHash = receiptFingerprint({ ...values, amountCents: Number(values.amountCents) }); } catch { /* Decimal exceeds the legacy number range. */ }
if (values.idempotencyKey) {
const [existing] = await db.select().from(financeEvents).where(and(eq(financeEvents.companyId, companyId), eq(financeEvents.idempotencyKey, values.idempotencyKey)));
if (existing) {
if (existing.receiptHash !== receiptHash && existing.receiptHash !== legacyHash) throw conflict("Idempotency key already used for a different finance receipt");
const [exact] = await db.select({ amount: sql<string>`${financeEvents.amountCents}::text` }).from(financeEvents).where(eq(financeEvents.id, existing.id));
return { ...existing, amountCentsExact: normalizeCents(exact.amount) };
}
}
if (data.agentId) await assertBelongsToCompany(db, agents, data.agentId, companyId, "Agent");
if (data.issueId) await assertBelongsToCompany(db, issues, data.issueId, companyId, "Issue");
if (data.projectId) await assertBelongsToCompany(db, projects, data.projectId, companyId, "Project");
@@ -51,35 +63,60 @@ export function financeService(db: Db) {
const event = await db
.insert(financeEvents)
.values({
...data,
companyId,
currency: data.currency ?? "USD",
direction: data.direction ?? "debit",
estimated: data.estimated ?? false,
...values, amountCents: sql`${values.amountCents}::numeric`, companyId, receiptHash, id: data.id,
})
.returning()
.then((rows) => rows[0]);
return event;
},
await logActivity(db, {
companyId, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? "finance_accounting", agentId: actor?.agentId,
action: "finance_event.reported", entityType: "finance_event", entityId: event.id,
details: { amountCents: event.amountCents, amountCentsExact: values.amountCents, currency: event.currency, biller: event.biller, eventKind: event.eventKind, direction: event.direction },
}, publications);
return { ...event, amountCentsExact: values.amountCents };
}
export function financeService(db: Db) {
const debitExpr = sql<string>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report' then ${financeEvents.amountCents} else 0 end), 0)`;
const creditExpr = sql<string>`coalesce(sum(case when ${financeEvents.direction} = 'credit' and ${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report' then ${financeEvents.amountCents} else 0 end), 0)`;
const estimatedDebitExpr = sql<string>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true and ${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report' then ${financeEvents.amountCents} else 0 end), 0)`;
return {
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId" | "receiptHash" | "amountCents"> & { amountCents: MoneyInput }, actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId">) => withAccountingTransaction(db, companyId, (tx, publications) => createFinanceEventInTransaction(tx, publications, companyId, data, actor)),
summary: async (companyId: string, range?: FinanceDateRange) => {
const conditions = rangeConditions(companyId, range);
const [row] = await db
const rows = await db
.select({
debitCents: debitExpr,
creditCents: creditExpr,
estimatedDebitCents: estimatedDebitExpr,
currency: financeEvents.currency,
providerReportedCentsExact: sql<string>`coalesce(sum(case when ${financeEvents.metadataJson}->>'source' = 'provider_cost_report' then case when ${financeEvents.direction} = 'credit' then -${financeEvents.amountCents} else ${financeEvents.amountCents} end else 0 end), 0)::text`,
debitCents: sql<number>`${debitExpr}::double precision`,
debitCentsExact: sql<string>`${debitExpr}::text`,
creditCents: sql<number>`${creditExpr}::double precision`,
creditCentsExact: sql<string>`${creditExpr}::text`,
estimatedDebitCents: sql<number>`${estimatedDebitExpr}::double precision`,
estimatedDebitCentsExact: sql<string>`${estimatedDebitExpr}::text`,
eventCount: sql<number>`count(*)::int`,
})
.from(financeEvents)
.where(and(...conditions));
.where(and(...conditions))
.groupBy(financeEvents.currency)
.orderBy(financeEvents.currency);
const row = rows.find((row) => row.currency === "USD");
return {
companyId,
currency: "USD" as const,
providerReportedCentsExact: normalizeCents(row?.providerReportedCentsExact ?? 0),
providerReportedCents: Number(row?.providerReportedCentsExact ?? 0),
currencies: rows.map((row) => ({ ...row, netCents: Number(subtractCents(row.debitCentsExact, row.creditCentsExact)), netCentsExact: subtractCents(row.debitCentsExact, row.creditCentsExact) })),
debitCents: Number(row?.debitCents ?? 0),
creditCents: Number(row?.creditCents ?? 0),
netCents: Number(row?.debitCents ?? 0) - Number(row?.creditCents ?? 0),
netCents: Number(subtractCents(row?.debitCentsExact ?? 0, row?.creditCentsExact ?? 0)),
netCentsExact: subtractCents(row?.debitCentsExact ?? 0, row?.creditCentsExact ?? 0),
debitCentsExact: normalizeCents(row?.debitCentsExact ?? 0),
creditCentsExact: normalizeCents(row?.creditCentsExact ?? 0),
estimatedDebitCentsExact: normalizeCents(row?.estimatedDebitCentsExact ?? 0),
estimatedDebitCents: Number(row?.estimatedDebitCents ?? 0),
eventCount: Number(row?.eventCount ?? 0),
};
@@ -87,44 +124,56 @@ export function financeService(db: Db) {
byBiller: async (companyId: string, range?: FinanceDateRange) => {
const conditions = rangeConditions(companyId, range);
conditions.push(sql`${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report'`);
return db
.select({
currency: financeEvents.currency,
biller: financeEvents.biller,
debitCents: debitExpr,
creditCents: creditExpr,
estimatedDebitCents: estimatedDebitExpr,
debitCents: sql<number>`${debitExpr}::double precision`,
debitCentsExact: sql<string>`${debitExpr}::text`,
creditCents: sql<number>`${creditExpr}::double precision`,
creditCentsExact: sql<string>`${creditExpr}::text`,
estimatedDebitCents: sql<number>`${estimatedDebitExpr}::double precision`,
estimatedDebitCentsExact: sql<string>`${estimatedDebitExpr}::text`,
eventCount: sql<number>`count(*)::int`,
kindCount: sql<number>`count(distinct ${financeEvents.eventKind})::int`,
netCents: sql<number>`(${debitExpr} - ${creditExpr})::double precision`,
netCentsExact: sql<string>`(${debitExpr} - ${creditExpr})::text`,
})
.from(financeEvents)
.where(and(...conditions))
.groupBy(financeEvents.biller)
.groupBy(financeEvents.biller, financeEvents.currency)
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller);
},
byKind: async (companyId: string, range?: FinanceDateRange) => {
const conditions = rangeConditions(companyId, range);
conditions.push(sql`${financeEvents.metadataJson}->>'source' is distinct from 'provider_cost_report'`);
return db
.select({
currency: financeEvents.currency,
eventKind: financeEvents.eventKind,
debitCents: debitExpr,
creditCents: creditExpr,
estimatedDebitCents: estimatedDebitExpr,
debitCents: sql<number>`${debitExpr}::double precision`,
debitCentsExact: sql<string>`${debitExpr}::text`,
creditCents: sql<number>`${creditExpr}::double precision`,
creditCentsExact: sql<string>`${creditExpr}::text`,
estimatedDebitCents: sql<number>`${estimatedDebitExpr}::double precision`,
estimatedDebitCentsExact: sql<string>`${estimatedDebitExpr}::text`,
eventCount: sql<number>`count(*)::int`,
billerCount: sql<number>`count(distinct ${financeEvents.biller})::int`,
netCents: sql<number>`(${debitExpr} - ${creditExpr})::double precision`,
netCentsExact: sql<string>`(${debitExpr} - ${creditExpr})::text`,
})
.from(financeEvents)
.where(and(...conditions))
.groupBy(financeEvents.eventKind)
.groupBy(financeEvents.eventKind, financeEvents.currency)
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind);
},
list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => {
const conditions = rangeConditions(companyId, range);
return db
.select()
.select({ ...getTableColumns(financeEvents), amountCentsExact: sql<string>`${financeEvents.amountCents}::text` })
.from(financeEvents)
.where(and(...conditions))
.orderBy(desc(financeEvents.occurredAt), desc(financeEvents.createdAt))
+263 -229
View File
@@ -22,8 +22,11 @@ import {
type NativeWorkspaceFinalizationOwnership,
} from "./native-runtime/native-workspace-finalization-ownership.js";
import { hasStopOnlyCleanup, settleStopOnlyCleanup } from "./sandbox-stop-and-retain.js";
import { reserveRunBudget } from "./budget-reservations.js";
import { accountRunCost, createCostAccountingReconciler } from "./run-cost-accounting.js";
import { createRunUsageRecorder } from "./usage-receipts.js";
import { applyWorkspaceRestoreFailure } from "@paperclipai/adapter-utils/workspace-restore-result";
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
import { compareCents, hasWorkspaceRestoreFailure } from "@paperclipai/shared";
import { externalConversationStateSql, nonIdleSlackIssueCondition } from "./slack-conversation-state.js";
import { settleSlackConversation } from "./slack-conversation-lifecycle.js";
import { publicChatTaskUrl } from "./chat-task-url.js";
@@ -90,7 +93,7 @@ import {
} from "@paperclipai/adapter-utils/execution-target";
import { agentService } from "./agents.js";
import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js";
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import { normalizeLegacyRunnerProvider, resolveManagedOpenAiBilling } from "@paperclipai/adapter-utils";
import fs from "node:fs/promises";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
@@ -318,7 +321,6 @@ import {
appendWithByteCap,
MAX_EXCERPT_BYTES,
} from "../adapters/utils.js";
import { costService } from "./costs.js";
import {
authorizeChatConversationForBoundRun,
isExternalChatWaitAuthorizationContention,
@@ -350,7 +352,7 @@ import {
import { reportRunFailure } from "./run-failure-report.js";
import { collectRunFailureSecretValues, type RunFailureReportOptions } from "./run-failure-diagnostics.js";
import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { budgetService, withCurrentBudgetEnforcement, type BudgetEnforcementScope } from "./budgets.js";
import { secretService, type MissingRuntimeBinding } from "./secrets.js";
import {
resolveDefaultAgentWorkspaceDir,
@@ -691,6 +693,7 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 50;
const LIVENESS_BOOKKEEPING_ACTIVITY_ACTIONS = [
"environment.lease_acquired",
"environment.lease_released",
"cost.reported",
];
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const EXTERNAL_ATTACHMENT_OMISSIONS_KEY = "externalAttachmentOmissions";
@@ -5344,21 +5347,23 @@ function resolveLedgerBiller(result: AdapterExecutionResult): string {
);
}
function normalizeBilledCostCents(
export function normalizeBilledCostCents(
costUsd: number | null | undefined,
billingType: BillingType,
): number {
if (billingType === "subscription_included") return 0;
if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return 0;
return Math.max(0, Math.round(costUsd * 100));
return Math.max(0, Number((costUsd * 100).toFixed(7)));
}
export function resolveLedgerCostStatus(input: {
costUsd: number | null | undefined;
billingType?: BillingType;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}): CostStatus {
if (input.billingType === "subscription_included") return "reported";
// A paused turn can have neither a token receipt nor a cost receipt. Zero
// normalized counters do not establish that its billed cost was zero.
return typeof input.costUsd === "number" &&
@@ -5564,12 +5569,13 @@ function readRawUsageTotals(usageJson: unknown): UsageTotals | null {
};
}
function deriveNormalizedUsageDelta(
export function normalizeAdapterRunUsage(
current: UsageTotals | null,
previous: UsageTotals | null,
usageBasis?: "per_run" | "session_cumulative" | null,
): UsageTotals | null {
if (!current) return null;
if (!previous) return { ...current };
if (!previous || usageBasis !== "session_cumulative") return { ...current };
const inputTokens =
current.inputTokens >= previous.inputTokens
@@ -12172,7 +12178,7 @@ export function heartbeatService(
// Adapters that declare per-run usage (e.g. the ACPX lane reports each
// turn's tokens, not session totals) must not be session-delta'd, or
// consecutive runs would be undercounted.
if (!sessionId || !rawUsage || usageBasis === "per_run") {
if (!sessionId || !rawUsage || usageBasis !== "session_cumulative") {
return {
normalizedUsage: rawUsage,
previousRawUsage: null as UsageTotals | null,
@@ -12185,7 +12191,7 @@ export function heartbeatService(
});
const previousRawUsage = readRawUsageTotals(previousRun?.usageJson);
return {
normalizedUsage: deriveNormalizedUsageDelta(rawUsage, previousRawUsage),
normalizedUsage: normalizeAdapterRunUsage(rawUsage, previousRawUsage, usageBasis),
previousRawUsage,
derivedFromSessionTotals: previousRawUsage !== null,
};
@@ -12949,6 +12955,10 @@ export function heartbeatService(
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
// Preserve the receipt-source fence when finalization enriches usage. A
// late spool replay must still be able to complete an unfinished receipt.
if (patch?.usageJson && previousStatus?.usageJson) patch = { ...patch, usageJson: { ...previousStatus.usageJson, ...patch.usageJson } };
// Cancelling a queued run that never acquired provider execution is
// positive bootstrap evidence. It must not hold unrelated queued messages.
if (previousStatus && (status === "cancelled" || status === "interrupted")) {
@@ -13043,6 +13053,10 @@ export function heartbeatService(
patch = { ...patch, resultJson: cancellationResultJson(previousStatus, status, patch?.resultJson, patch?.errorCode, patch?.error) };
}
// Preserve the receipt-source fence when finalization enriches usage. A
// late spool replay must still be able to complete an unfinished receipt.
if (patch?.usageJson && previousStatus?.usageJson) patch = { ...patch, usageJson: { ...previousStatus.usageJson, ...patch.usageJson } };
// Cancelling a queued run that never acquired provider execution is
// positive bootstrap evidence. It must not hold unrelated queued messages.
if (
@@ -16428,9 +16442,8 @@ export function heartbeatService(
.then((rows) => rows[0] ?? null);
}
// No code path raises the `ai_connection_busy` error any more. This
// function stays because a stored run row can still carry that error code
// from an earlier release.
// Credential rotation can briefly contend with a fresh runtime read. Keep
// the task on its automatic pre-provider retry path while the lock clears.
async function finalizeAiConnectionBusyDeferral(
run: typeof heartbeatRuns.$inferSelect,
error: HttpError,
@@ -16952,7 +16965,7 @@ export function heartbeatService(
if (checkCostCap && policy.maxDailyCostCents !== null) {
const [row] = await client
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents})::bigint, 0)`,
total: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
})
.from(costEvents)
.where(
@@ -16964,7 +16977,7 @@ export function heartbeatService(
),
);
const observed = Number(row?.total ?? 0);
if (observed >= policy.maxDailyCostCents) {
if (compareCents(String(row?.total ?? 0), policy.maxDailyCostCents) >= 0) {
return {
reason: "heartbeat.daily_cost_limit",
observed,
@@ -20030,32 +20043,7 @@ export function heartbeatService(
normalizedUsage?: UsageTotals | null,
) {
await ensureRuntimeState(agent);
const usage = normalizedUsage ?? normalizeUsageTotals(result.usage);
const inputTokens = usage?.inputTokens ?? 0;
const outputTokens = usage?.outputTokens ?? 0;
const cachedInputTokens = usage?.cachedInputTokens ?? 0;
const billingType = normalizeLedgerBillingType(result.billingType);
const billedCostUsd = resolveCacheAdjustedCostUsd(result);
const additionalCostCents = normalizeBilledCostCents(
billedCostUsd,
billingType,
);
const hasTokenUsage =
inputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0;
const costStatus = resolveLedgerCostStatus({
costUsd: billedCostUsd,
inputTokens,
cachedInputTokens,
outputTokens,
});
const provider = result.provider ?? "unknown";
const biller = resolveLedgerBiller(result);
const ledgerScope = await resolveLedgerScopeForRun(
db,
agent.companyId,
run,
);
await accountRunCost(db, run.id, budgetHooks);
await db
.update(agentRuntimeState)
.set({
@@ -20064,34 +20052,10 @@ export function heartbeatService(
lastRunId: run.id,
lastRunStatus: run.status,
lastError: run.error ?? null,
totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${inputTokens}`,
totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${outputTokens}`,
totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${cachedInputTokens}`,
totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${additionalCostCents}`,
updatedAt: new Date(),
})
.where(eq(agentRuntimeState.agentId, agent.id));
if (additionalCostCents > 0 || hasTokenUsage) {
const costs = costService(db, budgetHooks);
await costs.createEvent(agent.companyId, {
heartbeatRunId: run.id,
agentId: agent.id,
issueId: ledgerScope.issueId,
projectId: ledgerScope.projectId,
billingCode: ledgerScope.billingCode,
provider,
biller,
billingType,
costStatus,
model: result.model ?? "unknown",
inputTokens,
cachedInputTokens,
outputTokens,
costCents: additionalCostCents,
occurredAt: new Date(),
});
}
}
// A 403 from claimQueuedRun comes from the run's own persisted identity
@@ -21126,6 +21090,13 @@ export function heartbeatService(
issueContext.executionWorkspacePreference,
}
: null;
const storedLedgerScope = parseObject(parseObject(run.usageJson).ledgerScope);
const runLedgerScope = Object.keys(storedLedgerScope).length > 0
? storedLedgerScope
: await resolveLedgerScopeForRun(db, agent.companyId, run);
await db.update(heartbeatRuns).set({
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ ledgerScope: runLedgerScope })}::jsonb`,
}).where(eq(heartbeatRuns.id, run.id));
const continuationSummary = issueRef && !isConversation(issueContext)
? await getIssueContinuationSummaryDocument(db, issueRef.id)
: null;
@@ -22907,7 +22878,7 @@ export function heartbeatService(
},
);
}
const dispatchResolvedInteractionContinuationWithAtomicGate = async <T>(
const dispatchResolvedInteractionContinuationAfterAdmission = async <T>(
dispatch: (markDispatchStarted: () => void) => Promise<T>,
): Promise<
{ dispatched: true; resultPromise: Promise<T> } | { dispatched: false }
@@ -22973,6 +22944,30 @@ export function heartbeatService(
}
return { dispatched: false };
};
const dispatchResolvedInteractionContinuationWithAtomicGate = async <T>(
dispatch: (markDispatchStarted: () => void) => Promise<T>,
) => {
// Admission can wait for accounting locks. Finish that wait before the
// final ownership gate, whose callback must enter the adapter directly.
const reservation = await reserveRunBudget(db, run.companyId, run.id,
readNonEmptyString(runLedgerScope.projectId), runLedgerScope, runOptions.nativeLeaseOwner);
let entered = false;
try {
return await dispatchResolvedInteractionContinuationAfterAdmission((markDispatchStarted) => {
entered = true;
return dispatch(markDispatchStarted);
});
} finally {
if (!entered && !reservation.reused) {
await db.update(heartbeatRuns).set({ costAccountingPending: true,
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || '{"accountingProviderWorkStarted":false}'::jsonb`,
}).where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.costAccountedAt)));
await accountRunCost(db, run.id, budgetHooks).catch((err) => {
logger.warn({ err, runId: run.id }, "Undispatched reservation release remains pending");
});
}
}
};
if (!executionTarget || executionTarget.kind === "local") {
try {
runScratch = await prepareHeartbeatRunScratch({
@@ -24800,6 +24795,7 @@ export function heartbeatService(
adapterFinalizeOutcome = status;
};
const usageRecorder = await createRunUsageRecorder(db, { companyId: run.companyId, runId: run.id, adapterType: agent.adapterType });
let adapterResult: AdapterExecutionResult;
const runGoalControlRequestId = readNonEmptyString(
context.goalControlRequestId,
@@ -24882,8 +24878,8 @@ export function heartbeatService(
);
const guardedDispatch =
await dispatchResolvedInteractionContinuationWithAtomicGate(
(markDispatchStarted) =>
executePaperclipNativeSession({
(markDispatchStarted) => {
return executePaperclipNativeSession({
db,
execution: nativeExecution,
getFreshSessionHandoff: getNativeFreshSessionHandoff,
@@ -24940,11 +24936,14 @@ export function heartbeatService(
hasChanges: () => instructionCopies.hasChanges({ companyId: agent.companyId, runId: run.id, target: executionTarget }),
collectStopped: collectStoppedInstructions,
} : undefined,
onUsage: async receipt => { await usageRecorder.capture(receipt); },
preparationSpans: nativeRunnerPreparationSpans,
// Bootstrap with executable/home discovery while keeping
// configured provider values and the server-selected
// workspace boundary authoritative.
managedGitHub: !useHostGitHub && githubSelection.configured,
billingIdentity: managedAiRuntime ? { provider: managedAiRuntime.attribution.provider, biller: managedAiRuntime.attribution.provider === "openai" ? resolveManagedOpenAiBilling(managedAiRuntime.config.managedAiRouting)?.biller ?? managedAiRuntime.attribution.provider : managedAiRuntime.attribution.provider, billingType: managedAiRuntime.attribution.method === "subscription" ? "subscription_included" : "metered_api" } : undefined,
managedAiCredentialIdentity: managedAiRuntime?.identity,
managedAiCredentialHome: managedAiRuntime ? String((managedAiRuntime.config.env as Record<string, unknown>).CODEX_HOME) : undefined,
runnerEnvironment: {
@@ -25017,7 +25016,8 @@ export function heartbeatService(
markDispatchStarted();
await persistRunProcessMetadata(run.id, meta);
},
}),
});
},
);
if (!guardedDispatch.dispatched) return;
nativeDispatchStarted = true;
@@ -25133,6 +25133,7 @@ export function heartbeatService(
onLog,
onMeta: onAdapterMeta,
onEvent: onAdapterEvent,
onUsage: async receipt => { await usageRecorder.capture(receipt); },
onExecutionPhase: executionControl.phases.enter,
startupTraceContext: getStartupTraceContext(),
onRuntimeProgress: async (progress) => {
@@ -25197,6 +25198,17 @@ export function heartbeatService(
if (tail) await appendIdentityRedactedLog(stream, tail);
}
if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave };
if (parseObject(adapterResult.executionRecovery).providerWorkStarted !== false) {
const captured = await usageRecorder.complete(adapterResult);
adapterResult = { ...adapterResult, ...captured, usageComplete: captured.complete };
} else {
// Stop may already own the terminal result. Preserve its metadata
// while durably recording the proof needed to release admission.
await db.update(heartbeatRuns).set({ costAccountingPending: true,
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || '{"accountingProviderWorkStarted":false}'::jsonb`,
}).where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.costAccountedAt)));
}
adapterResult = applyWorkspaceRestoreFailure(adapterResult);
// A returned result can include a failed restore. Keep the workspace
// barrier closed until required files have been restored.
@@ -25637,65 +25649,67 @@ export function heartbeatService(
? "timed_out"
: "failed";
const cacheAdjustedCostUsd = resolveCacheAdjustedCostUsd(adapterResult);
const usageJson =
normalizedUsage ||
adapterResult.costUsd != null ||
cacheAdjustedCostUsd != null
? ({
...(normalizedUsage ?? {}),
...(rawUsage
? {
rawInputTokens: rawUsage.inputTokens,
rawInputIncludesCached: false,
rawCachedInputTokens: rawUsage.cachedInputTokens,
rawOutputTokens: rawUsage.outputTokens,
}
: {}),
...(sessionUsageResolution.derivedFromSessionTotals
? { usageSource: "session_delta" }
: adapterResult.usageBasis === "per_run"
? { usageSource: "per_run" }
: {}),
...((nextSessionState.displayId ??
nextSessionState.legacySessionId)
? {
persistedSessionId:
nextSessionState.displayId ??
nextSessionState.legacySessionId,
}
: {}),
sessionReused:
runtimeForAdapter.sessionId != null ||
runtimeForAdapter.sessionDisplayId != null,
taskSessionReused: taskSessionForRun != null,
freshSession:
runtimeForAdapter.sessionId == null &&
runtimeForAdapter.sessionDisplayId == null,
sessionRotated: sessionCompaction.rotate,
sessionRotationReason: sessionCompaction.reason,
configFreshness: configFreshnessResultMetadata,
provider:
readNonEmptyString(adapterResult.provider) ?? "unknown",
biller: resolveLedgerBiller(adapterResult),
model: readNonEmptyString(adapterResult.model) ?? "unknown",
...(adapterResult.costUsd != null
? { costUsd: adapterResult.costUsd }
: {}),
...(cacheAdjustedCostUsd != null
? { cacheAdjustedCostUsd }
: {}),
costStatus: resolveLedgerCostStatus({
costUsd: cacheAdjustedCostUsd,
inputTokens: normalizedUsage?.inputTokens ?? 0,
cachedInputTokens: normalizedUsage?.cachedInputTokens ?? 0,
outputTokens: normalizedUsage?.outputTokens ?? 0,
}),
billingType: normalizeLedgerBillingType(
adapterResult.billingType,
),
} as Record<string, unknown>)
: null;
const cacheAdjustedCostUsd = adapterResult.costUsdExact != null && adapterResult.cacheAdjustedCostUsd == null
? null : resolveCacheAdjustedCostUsd(adapterResult);
const usageJson: Record<string, unknown> = {
accountingReceiptReady: adapterResult.usageComplete !== false,
costUsdExact: adapterResult.costUsdExact ?? null,
providerRequestId: adapterResult.providerRequestId ?? null,
...(normalizedUsage ?? {}),
...(adapterResult.usageByModel ? { usageByModel: adapterResult.usageByModel } : {}),
...(rawUsage
? {
rawInputTokens: rawUsage.inputTokens,
rawInputIncludesCached: false,
rawCachedInputTokens: rawUsage.cachedInputTokens,
rawOutputTokens: rawUsage.outputTokens,
}
: {}),
...(sessionUsageResolution.derivedFromSessionTotals
? { usageSource: "session_delta" }
: adapterResult.usageBasis === "per_run"
? { usageSource: "per_run" }
: {}),
...((nextSessionState.displayId ??
nextSessionState.legacySessionId)
? {
persistedSessionId:
nextSessionState.displayId ??
nextSessionState.legacySessionId,
}
: {}),
sessionReused:
runtimeForAdapter.sessionId != null ||
runtimeForAdapter.sessionDisplayId != null,
taskSessionReused: taskSessionForRun != null,
freshSession:
runtimeForAdapter.sessionId == null &&
runtimeForAdapter.sessionDisplayId == null,
sessionRotated: sessionCompaction.rotate,
sessionRotationReason: sessionCompaction.reason,
configFreshness: configFreshnessResultMetadata,
provider:
readNonEmptyString(adapterResult.provider) ?? "unknown",
biller: resolveLedgerBiller(adapterResult),
model: readNonEmptyString(adapterResult.model) ?? "unknown",
...(adapterResult.costUsd != null
? { costUsd: adapterResult.costUsd }
: {}),
...(cacheAdjustedCostUsd != null
? { cacheAdjustedCostUsd }
: {}),
pricingProvenance: adapterResult.pricingProvenance,
costStatus: adapterResult.costStatus ?? resolveLedgerCostStatus({
costUsd: cacheAdjustedCostUsd ?? (adapterResult.costUsdExact != null ? Number(adapterResult.costUsdExact) : null),
billingType: normalizeLedgerBillingType(adapterResult.billingType),
inputTokens: normalizedUsage?.inputTokens ?? 0,
cachedInputTokens: normalizedUsage?.cachedInputTokens ?? 0,
outputTokens: normalizedUsage?.outputTokens ?? 0,
}),
billingType: normalizeLedgerBillingType(
adapterResult.billingType,
),
};
const persistedResultJson = cancellationResultJson(latestRun ?? run, outcome, mergeHeartbeatRunResultJson(
mergeRunStopMetadataForAgent(agent, outcome, {
@@ -25719,13 +25733,17 @@ export function heartbeatService(
adapterResult.summary ?? null,
), runErrorCode, runErrorMessage);
const ledgerScope = runLedgerScope;
const finalRunPatch: Partial<typeof heartbeatRuns.$inferInsert> = {
// Accounting must acknowledge even a proven pre-provider failure:
// that transaction releases the reservation without creating a charge.
costAccountingPending: true,
finishedAt: new Date(),
error: runErrorMessage,
errorCode: runErrorCode,
exitCode: adapterResult.exitCode,
signal: adapterResult.signal,
usageJson,
usageJson: { ...usageJson, ledgerScope },
resultJson: persistedResultJson,
sessionIdAfter:
nextSessionState.displayId ?? nextSessionState.legacySessionId,
@@ -25763,6 +25781,8 @@ export function heartbeatService(
.set({
...finalRunPatch,
resultJson: cancellationResultJson(persistedRunWrite.run, status, finalRunPatch.resultJson, runErrorCode, runErrorMessage),
usageJson: { ...parseObject(persistedRunWrite.run.usageJson), ...parseObject(finalRunPatch.usageJson) },
finishedAt:
persistedRunWrite.run.finishedAt ?? finalRunPatch.finishedAt,
updatedAt: new Date(),
@@ -25777,6 +25797,9 @@ export function heartbeatService(
.then((rows) => rows[0] ?? null);
}
if (!persistedRun) {
await accountRunCost(db, run.id, budgetHooks).catch((err) => {
logger.error({ err, runId: run.id }, "Late run accounting queued for recovery");
});
logger.info(
{
runId: run.id,
@@ -25789,6 +25812,10 @@ export function heartbeatService(
}
}
if (persistedRun) {
// Accounting recovery is independent of workspace or issue finalization.
await accountRunCost(db, persistedRun.id, budgetHooks).catch((err) => {
logger.error({ err, runId: run.id }, "Run accounting queued for recovery");
});
persistedRun =
(await classifyAndPersistRunLiveness(
persistedRun,
@@ -29512,7 +29539,7 @@ export function heartbeatService(
return { scanned: candidates.length, dispatched, recovered, deferred };
}
async function listProjectScopedRunIds(companyId: string, projectId: string) {
async function listProjectScopedRunIds(companyId: string, projectId: string, createdBefore?: Date) {
const runIssueId = sql<
string | null
>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`;
@@ -29533,6 +29560,7 @@ export function heartbeatService(
.where(
and(
eq(heartbeatRuns.companyId, companyId),
createdBefore ? lt(heartbeatRuns.createdAt, createdBefore) : undefined,
inArray(heartbeatRuns.status, [
...CANCELLABLE_HEARTBEAT_RUN_STATUSES,
]),
@@ -29546,6 +29574,7 @@ export function heartbeatService(
async function listProjectScopedWakeupIds(
companyId: string,
projectId: string,
database: Db = db,
) {
const wakeIssueId = sql<
string | null
@@ -29554,7 +29583,7 @@ export function heartbeatService(
string | null
>`coalesce(${agentWakeupRequests.payload} ->> 'projectId', ${issues.projectId}::text)`;
const rows = await db
const rows = await database
.selectDistinctOn([agentWakeupRequests.id], {
id: agentWakeupRequests.id,
})
@@ -29584,63 +29613,72 @@ export function heartbeatService(
async function cancelPendingWakeupsForBudgetScope(
scope: BudgetEnforcementScope,
) {
const now = new Date();
let wakeupIds: string[] = [];
return withCurrentBudgetEnforcement(db, scope, async (tx) => {
const now = new Date();
let wakeupIds: string[] = [];
if (scope.scopeType === "company") {
wakeupIds = await db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, scope.companyId),
inArray(agentWakeupRequests.status, [
"queued",
"deferred_issue_execution",
]),
sql`${agentWakeupRequests.runId} is null`,
),
)
.then((rows) => rows.map((row) => row.id));
} else if (scope.scopeType === "agent") {
wakeupIds = await db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, scope.companyId),
eq(agentWakeupRequests.agentId, scope.scopeId),
inArray(agentWakeupRequests.status, [
"queued",
"deferred_issue_execution",
]),
sql`${agentWakeupRequests.runId} is null`,
),
)
.then((rows) => rows.map((row) => row.id));
} else {
wakeupIds = await listProjectScopedWakeupIds(
scope.companyId,
scope.scopeId,
);
}
if (scope.scopeType === "company") {
wakeupIds = await tx
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, scope.companyId),
inArray(agentWakeupRequests.status, [
"queued",
"deferred_issue_execution",
]),
sql`${agentWakeupRequests.runId} is null`,
),
)
.then((rows) => rows.map((row) => row.id));
} else if (scope.scopeType === "agent") {
wakeupIds = await tx
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, scope.companyId),
eq(agentWakeupRequests.agentId, scope.scopeId),
inArray(agentWakeupRequests.status, [
"queued",
"deferred_issue_execution",
]),
sql`${agentWakeupRequests.runId} is null`,
),
)
.then((rows) => rows.map((row) => row.id));
} else {
wakeupIds = await listProjectScopedWakeupIds(
scope.companyId,
scope.scopeId,
tx,
);
}
if (wakeupIds.length === 0) return 0;
if (wakeupIds.length === 0) return 0;
await db
.update(agentWakeupRequests)
.set({
status: "cancelled",
finishedAt: now,
error: "Cancelled due to budget pause",
updatedAt: now,
})
.where(inArray(agentWakeupRequests.id, wakeupIds));
await tx
.update(agentWakeupRequests)
.set({
status: "cancelled",
finishedAt: now,
error: "Cancelled due to budget pause",
updatedAt: now,
})
.where(and(
inArray(agentWakeupRequests.id, wakeupIds),
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]),
isNull(agentWakeupRequests.runId),
scope.createdBefore ? lt(agentWakeupRequests.createdAt, scope.createdBefore) : undefined,
));
return wakeupIds.length;
return wakeupIds.length;
});
}
type CancelRunOptions = {
budgetEnforcement?: BudgetEnforcementScope;
/** Optional board request identity, atomically reserved for native Stop. */
cancellationRequestId?: string;
cancellationRequestedByUserId?: string | null;
@@ -29725,25 +29763,40 @@ export function heartbeatService(
// Established legacy processes must still be stopped if the database is
// unavailable. Only native or not-yet-dispatched preparation needs this
// additional durable fence before its existing cancellation path.
if (!options.cancellationRequestId && (run.runtimeMode === "native" || (!run.runtimeModeResolvedAt && !running && !control))) {
const [fenced] = await db.update(heartbeatRuns).set({
// Record handoff intent before native cancellation can finalize and release
// the run. Only its audited stop acknowledgement suppresses recovery.
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) ||
${JSON.stringify(options.errorCode === "issue_reassigned" && options.resultJson?.reassignmentStopConfirmed === true
? { reassignmentStopRequested: true } : {})}::jsonb ||
${JSON.stringify({ cancellation })}::jsonb ||
jsonb_build_object('startupCancellation', jsonb_build_object(
'requestedAt', ${new Date().toISOString()}::text,
'beforeNativeSelection', ${heartbeatRuns.runtimeMode} = 'legacy'
and ${heartbeatRuns.runtimeModeResolvedAt} is null
and ${heartbeatRuns.executionStage} = 'preparing'
and coalesce(${heartbeatRuns.runnerProfileJson}->'adapterDispatch'->>'adapterType' = 'paperclip_runner', false)
))`,
}).where(and(eq(heartbeatRuns.id, runId), inArray(heartbeatRuns.status,
pendingNativeRetry ? [...CANCELLABLE_HEARTBEAT_RUN_STATUSES, "failed"] : [...CANCELLABLE_HEARTBEAT_RUN_STATUSES],
))).returning();
if (!fenced) return getRun(runId);
if (options.budgetEnforcement || (!options.cancellationRequestId && (run.runtimeMode === "native" || (!run.runtimeModeResolvedAt && !running && !control)))) {
const fence = async (tx: Db) => {
const [fenced] = await tx.update(heartbeatRuns).set({
// Record handoff intent before native cancellation can finalize and release
// the run. Only its audited stop acknowledgement suppresses recovery.
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) ||
${JSON.stringify(options.errorCode === "issue_reassigned" && options.resultJson?.reassignmentStopConfirmed === true
? { reassignmentStopRequested: true } : {})}::jsonb ||
${JSON.stringify({ cancellation })}::jsonb ||
jsonb_build_object('startupCancellation', jsonb_build_object(
'requestedAt', ${new Date().toISOString()}::text,
'beforeNativeSelection', ${heartbeatRuns.runtimeMode} = 'legacy'
and ${heartbeatRuns.runtimeModeResolvedAt} is null
and ${heartbeatRuns.executionStage} = 'preparing'
and coalesce(${heartbeatRuns.runnerProfileJson}->'adapterDispatch'->>'adapterType' = 'paperclip_runner', false)
))`,
}).where(and(eq(heartbeatRuns.id, runId), inArray(heartbeatRuns.status,
pendingNativeRetry ? [...CANCELLABLE_HEARTBEAT_RUN_STATUSES, "failed"] : [...CANCELLABLE_HEARTBEAT_RUN_STATUSES],
))).returning();
return fenced ?? null;
};
let fenced: typeof run | null;
try {
fenced = options.budgetEnforcement
? await withCurrentBudgetEnforcement(db, options.budgetEnforcement, fence)
: await fence(db);
} catch (error) {
stopOwnership?.release();
throw error;
}
if (!fenced) {
stopOwnership?.release();
return getRun(runId);
}
run = fenced;
}
const resultJson = { ...(agent
@@ -30118,35 +30171,15 @@ export function heartbeatService(
}
async function cancelBudgetScopeWork(scope: BudgetEnforcementScope) {
if (scope.scopeType === "agent") {
await cancelActiveForAgentInternal(
scope.scopeId,
"Cancelled due to budget pause",
);
await cancelPendingWakeupsForBudgetScope(scope);
return;
}
const runIds =
scope.scopeType === "company"
? await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, scope.companyId),
inArray(heartbeatRuns.status, [
...CANCELLABLE_HEARTBEAT_RUN_STATUSES,
]),
),
)
.then((rows) => rows.map((row) => row.id))
: await listProjectScopedRunIds(scope.companyId, scope.scopeId);
for (const runId of runIds) {
await cancelRunInternal(runId, "Cancelled due to budget pause");
}
const runIds = scope.scopeType === "project"
? await listProjectScopedRunIds(scope.companyId, scope.scopeId, scope.createdBefore)
: await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, scope.companyId),
scope.scopeType === "agent" ? eq(heartbeatRuns.agentId, scope.scopeId) : undefined,
scope.createdBefore ? lt(heartbeatRuns.createdAt, scope.createdBefore) : undefined,
inArray(heartbeatRuns.status, [...CANCELLABLE_HEARTBEAT_RUN_STATUSES]),
)).then((rows) => rows.map((row) => row.id));
for (const runId of runIds) await cancelRunInternal(runId, "Cancelled due to budget pause", { budgetEnforcement: scope });
await cancelPendingWakeupsForBudgetScope(scope);
}
@@ -30515,6 +30548,7 @@ export function heartbeatService(
scanSilentActiveRuns,
reconcileTaskWatchdogs,
reconcileCostAccounting: createCostAccountingReconciler(db, budgetHooks),
buildRunOutputSilence,
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { PgDialect } from "drizzle-orm/pg-core";
import type { SQL } from "drizzle-orm";
import {
access,
cp,
@@ -195,6 +197,18 @@ const state = vi.hoisted(() => ({
release: null as null | (() => void),
}));
const accountingEvents = vi.hoisted(() => ({ committed: undefined as undefined | ((event: PrpEvent) => Promise<void>), duplicate: undefined as undefined | ((event: PrpEvent) => Promise<void>) }));
vi.mock("./paperclip-control-plane-port.js", async importOriginal => {
const original = await importOriginal<typeof import("./paperclip-control-plane-port.js")>();
return { ...original, PaperclipControlPlanePort: class extends original.PaperclipControlPlanePort {
constructor(...args: ConstructorParameters<typeof original.PaperclipControlPlanePort>) {
super(...args);
accountingEvents.committed = args[2]?.onCommittedEvent;
accountingEvents.duplicate = args[2]?.onDuplicateEvent;
}
} };
});
const grokCopyBack = vi.hoisted(() => vi.fn(async (_input: { readSandboxAuth: () => Promise<Buffer>; hostHomeDir: string }) => undefined));
vi.mock("@paperclipai/adapter-grok-local/server", async importOriginal => ({
...await importOriginal<typeof import("@paperclipai/adapter-grok-local/server")>(),
@@ -309,6 +323,7 @@ import {
nativeUsageCostUsd,
nativeUsageBiller,
normalizeNativeUsage,
resolveNativeBilling,
parseRemoteRunnerProcessIdentity,
REMOTE_RUNNER_CHILD_LAUNCH_SCRIPT,
verifyRemoteRunnerReattachment,
@@ -984,6 +999,18 @@ describe("native incomplete-bootstrap evidence", () => {
});
describe("native provider usage normalization", () => {
it.each([
{}, { inputTokens: 1 }, { outputTokens: 1 },
{ inputTokens: 0, outputTokens: -1 }, { inputTokens: 0.5, outputTokens: 1 },
{ inputTokens: 10, outputTokens: 1, cacheReadTokens: 20 },
{ inputTokens: 10, outputTokens: 1, cacheReadTokens: -1 },
])("does not invent complete native counters from %j", runDelta => {
expect(normalizeNativeUsage({ runDelta, total: { inputTokens: 100, outputTokens: 10 } }, { inputIncludesCacheReads: true })).toBeUndefined();
});
it("does not use a cumulative price or protocol-default zero for an incomplete delta", () => {
expect(nativeUsageCostUsd({ runDelta: { inputTokens: 10, outputTokens: 1 }, providerCostUsd: 100 })).toBeUndefined();
expect(nativeUsageCostUsd({ runDelta: { providerCostUsd: 0 } })).toBeUndefined();
});
it.each([["cursor", "cursor"], ["copilot", "github"], ["pi", "openrouter"]] as const)("keeps %s cost unknown and attributes its actual biller", (agent, biller) => {
const provider = { kind: "acpx", agent, model: "exact-model" } as NativeExecutionInput["provider"];
expect(nativeUsageBiller(provider)).toBe(biller);
@@ -991,6 +1018,35 @@ describe("native provider usage normalization", () => {
expect(nativeUsageCostUsd(usage, provider)).toBeUndefined();
expect(normalizeNativeUsage(usage)).toMatchObject({ inputTokens: 100, outputTokens: 12 });
});
it("retains cache writes and avoids counting Codex cache hits twice", () => {
expect(normalizeNativeUsage({ runDelta: { inputTokens: 120, cacheReadTokens: 100, cacheWriteTokens: 5, outputTokens: 10 } }, { inputIncludesCacheReads: true }))
.toEqual({ inputTokens: 20, cachedInputTokens: 100, cacheWriteTokens: 5, outputTokens: 10 });
});
it("does not treat a protocol-default price as free usage", () => {
expect(nativeUsageCostUsd({ runDelta: { inputTokens: 20, outputTokens: 10, providerCostUsd: 0 } })).toBeUndefined();
});
it("reports the actual runtime billing identity", () => {
expect(resolveNativeBilling({ kind: "opencode", model: "openrouter/deepseek/test", permissionMode: "allow" }))
.toEqual({ provider: "deepseek", biller: "openrouter", billingType: "unknown" });
expect(resolveNativeBilling({ kind: "acpx", agent: "claude", model: "claude-test", permissionMode: "approve-all",
profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "claude", agentProfileVersion: 1,
agentServerPackage: "@zed-industries/claude-agent-acp", agentServerVersion: "1", agentRuntimePackage: null,
agentRuntimeVersion: null, commandDigest: "fixture" },
}, { ANTHROPIC_API_KEY: "test" }))
.toEqual({ provider: "anthropic", biller: "anthropic", billingType: "metered_api" });
});
it("prefers the adjusted turn price to cumulative session prices", () => {
expect(nativeUsageCostUsd({ providerCostUsd: 10, runDelta: { inputTokens: 10, providerCostUsd: 2, cacheAdjustedCostUsd: 1 } })).toBe(1);
});
it.each(["OPENAI_BASE_URL", "OPENAI_API_BASE", "OPENAI_API_BASE_URL"])("does not price native or managed proxy traffic as direct OpenAI using %s", key => {
const provider = { kind: "codex", model: "gpt-6-astra" } as Parameters<typeof resolveNativeBilling>[0];
expect(resolveNativeBilling(provider, { OPENAI_API_KEY: "fixture", [key]: "https://proxy.example/v1" }).biller).toBe("unknown");
expect(resolveNativeBilling(provider, { [key]: "https://proxy.example/v1" }, { provider: "openai", biller: "openai", billingType: "metered_api" }).biller).toBe("unknown");
expect(resolveNativeBilling(provider, {}, { provider: "openai", biller: "openai", billingType: "metered_api" })).toMatchObject({ biller: "openai", billingType: "metered_api" });
});
it("reads remote runner run-delta tokens and provider cost", () => {
const usage = {
total: {
@@ -4852,6 +4908,7 @@ function leaseDb(
updates: Array<{ table: unknown; values: Record<string, unknown> }> = [],
runnerProfileJson: Record<string, unknown> = {},
runStatus = "running",
priorAccountingEvents: PrpEvent[] = [],
): Db {
const coordinator: LeaseCoordinator = {
runId: boundExecution.binding.runId,
@@ -4910,13 +4967,15 @@ function leaseDb(
checkoutRunId: null,
},
]
: [];
: table === heartbeatRunEvents
? priorAccountingEvents.map((event, seq) => ({ seq, eventType: event.eventType, sourceSeq: event.sourceSeq, payload: { prpEvent: event } }))
: [];
const query = {
then: Promise.resolve(rows).then.bind(Promise.resolve(rows)),
where: () => query,
orderBy: () => query,
for: () => query,
limit: () => Promise.resolve(rows),
limit: () => query,
};
return query;
},
@@ -4924,7 +4983,9 @@ function leaseDb(
const insert = (table: unknown) => ({
values: (values: Record<string, unknown>) => {
updates.push({ table, values });
return { returning: async () => [values] };
const query = { returning: async () => [values], onConflictDoUpdate: () => query, onConflictDoNothing: () => query,
then: Promise.resolve([values]).then.bind(Promise.resolve([values])) };
return query;
},
});
const tx = {
@@ -5177,6 +5238,268 @@ describe("native startup restart detachment", () => {
});
});
describe("native terminal-turn accounting", () => {
it.each(["claude", "codex"].flatMap(agent => [false, true].flatMap(restart =>
[false, true].flatMap(partialFirst => [false, true].map(zeroFirst => ({ agent, restart, partialFirst, zeroFirst }))),
)))("accumulates ACPX turns for $agent (restart: $restart, partial: $partialFirst, zero first: $zeroFirst)", async ({ agent, restart, partialFirst, zeroFirst }) => {
const { rehydrateRunnerdUsageNotification } = await import("@paperclipai/paperclip-runner/live");
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
const event = (eventType: string, turnId: string, sourceSeq: number, payload: object = {}) => ({
eventType, turnId, sourceSeq, sourceInstanceId: "provider", emittedAt: new Date().toISOString(), payload,
}) as unknown as PrpEvent;
const usage = (turnId: string, inputTokens: number, outputTokens: number, complete: boolean) => ({
kind: "usage", usage: rehydrateRunnerdUsageNotification({ provider: "acpx", runDeltaAvailable: complete,
cumulative: { inputTokens: 0, outputTokens: 0, providerCostUsd: 10 },
runDelta: { inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0, providerCostUsd: 0 },
}, "session", turnId).tokenUsage,
});
const first = [event("turn.started", "first", 1), event("item.completed", "first", 2, usage("first", zeroFirst ? 0 : 12, zeroFirst ? 0 : 4, !partialFirst)), event("turn.failed", "first", 3)];
const second = [event("turn.started", "second", 4), event("item.completed", "second", 5, usage("second", 20, 6, true)), event("turn.completed", "second", 6)];
state.execute.mockReset().mockImplementationOnce(async () => {
for (const entry of [...(restart ? [] : first), ...second]) await accountingEvents.committed!(entry);
for (const entry of [...first, ...second]) await accountingEvents.duplicate!(entry);
return { result: { summary: "cancelled" }, terminal: { runTerminalState: "cancelled" }, turnId: "second",
normalizedSessionId: "session", providerSessionId: null, driverKind: "acpx_runtime", driverVersion: "1",
nativeEventCount: 6, highestContiguousSourceSeq: 6, usage: null };
});
const result = await executePaperclipNativeSession({ db: leaseDb(execution, {}, {}, [], {}, "running", restart ? first : []),
execution: { ...execution, provider: { kind: "acpx", agent, model: agent === "codex" ? "gpt-6-astra" : "claude-sonnet-4-6" } } as NativeExecutionInput,
runnerInstanceId: "runner", runnerEnvironment: { OPENAI_API_KEY: "fixture" }, onUsage });
expect(result).toMatchObject({ usageComplete: !partialFirst,
usage: { inputTokens: partialFirst || zeroFirst ? 20 : 32, outputTokens: partialFirst || zeroFirst ? 6 : 10 } });
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: !partialFirst }));
if (partialFirst) expect(result.costStatus).toBe("unpriced");
if (zeroFirst && !partialFirst) {
expect(result.costUsdExact).toBeUndefined();
expect(result.costUsd).toBeNull();
}
if (agent === "codex" && !partialFirst) {
const { priceCodexReceipt } = await import("../codex-pricing.js");
const priced = priceCodexReceipt(onUsage.mock.calls.at(-1)![0]);
expect(priced.costStatus).toBe("estimated");
expect(Number(priced.costUsdExact)).toBeCloseTo(zeroFirst ? 0.0005 : 0.00082, 9);
}
});
it.each(["claude", "codex"].flatMap(agent =>
["missing", "partial", "complete_zero", "later_partial", "recovered"].map(scenario => ({ agent, scenario })),
))("honors runner completeness for ACPX $agent ($scenario)", async ({ agent, scenario }) => {
const { rehydrateRunnerdUsageNotification } = await import("@paperclipai/paperclip-runner/live");
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
const complete = ["complete_zero", "recovered"].includes(scenario);
const zero = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, providerCostUsd: 0 };
const full = { ...zero, inputTokens: 12, outputTokens: 4 };
const reports = scenario === "complete_zero" ? [{ runDelta: zero, runDeltaAvailable: true }]
: scenario === "later_partial" ? [{ runDelta: full, runDeltaAvailable: true }, { runDelta: zero, runDeltaAvailable: false }]
: scenario === "recovered" ? [{ runDelta: zero, runDeltaAvailable: false }, { runDelta: full, runDeltaAvailable: true }]
: [{ runDelta: scenario === "partial" ? { ...zero, inputTokens: 12 } : zero, runDeltaAvailable: false }];
state.execute.mockReset().mockImplementationOnce(async () => {
for (const observe of [accountingEvents.committed!, accountingEvents.duplicate!]) {
for (const report of reports) {
const notification = rehydrateRunnerdUsageNotification({ provider: "acpx", cumulative: { ...full, providerCostUsd: 10 }, ...report }, "session", "turn");
await observe({ eventType: "item.completed", turnId: "turn", payload: { kind: "usage", usage: notification.tokenUsage } } as unknown as PrpEvent);
}
await observe({ eventType: "turn.completed", turnId: "turn", payload: {} } as unknown as PrpEvent);
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete }));
if (!complete) expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ costUsd: null, costStatus: "unpriced" }));
}
return { result: { summary: "cancelled" }, terminal: { runTerminalState: "cancelled" }, turnId: "turn",
normalizedSessionId: "session", providerSessionId: null, driverKind: "acpx_runtime", driverVersion: "1", nativeEventCount: 2, highestContiguousSourceSeq: 2,
usage: { runDelta: full, runDeltaComplete: true } };
});
const result = await executePaperclipNativeSession({ db: leaseDb(), execution: { ...execution,
provider: { kind: "acpx", agent, model: agent === "codex" ? "gpt-6-astra" : "claude-sonnet-4-6" } } as NativeExecutionInput,
runnerInstanceId: "runner", onUsage });
expect(result).toMatchObject({ usageComplete: complete });
if (!complete) expect(result).toMatchObject({ costUsd: null, costStatus: "unpriced" });
else expect(result.usage).toMatchObject({ inputTokens: scenario === "complete_zero" ? 0 : 12, outputTokens: scenario === "complete_zero" ? 0 : 4 });
});
it.each([0, 0.25])("keeps interrupted AgentCore accounting pending despite cumulative cost %s", async providerCostUsd => {
const { rehydrateRunnerdUsageNotification } = await import("@paperclipai/paperclip-runner/live");
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
const total = { inputTokens: 10, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0, providerCostUsd };
const notification = rehydrateRunnerdUsageNotification({ provider: "aws_agentcore", cumulative: total, runDelta: null, runDeltaAvailable: false }, "session", "turn");
state.execute.mockReset().mockImplementationOnce(async () => {
for (const observe of [accountingEvents.committed!, accountingEvents.duplicate!]) {
await observe({ eventType: "item.completed", turnId: "turn", payload: { kind: "usage", usage: notification.tokenUsage } } as unknown as PrpEvent);
await observe({ eventType: "turn.interrupted", turnId: "turn", payload: {} } as unknown as PrpEvent);
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: false, costStatus: "unpriced" }));
}
return { result: { summary: "interrupted" }, terminal: { runTerminalState: "cancelled" }, turnId: "turn",
normalizedSessionId: "session", providerSessionId: null, driverKind: "test", driverVersion: "1", nativeEventCount: 2, highestContiguousSourceSeq: 2, usage: { total } };
});
const result = await executePaperclipNativeSession({ db: leaseDb(), execution: { ...execution, provider: { kind: "aws_agentcore", model: "managed-model", agentCoreProfile: { profileId: "agentcore-test" } } } as unknown as NativeExecutionInput, runnerInstanceId: "runner", onUsage });
expect(result).toMatchObject({ usageComplete: false, costStatus: "unpriced", billingType: "metered_api" });
});
it.each(["claude_managed", "aws_agentcore"] as const)("settles fresh runner receipts for %s and preserves estimated pricing", async kind => {
const { rehydrateRunnerdUsageNotification } = await import("@paperclipai/paperclip-runner/live");
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
const total = { inputTokens: 121, outputTokens: 18, cacheReadTokens: 33, cacheWriteTokens: 15, providerCostUsd: 1.25 };
const delta = { inputTokens: 21, outputTokens: 8, cacheReadTokens: 13, cacheWriteTokens: 5, providerCostUsd: 0.25 };
const notification = rehydrateRunnerdUsageNotification({ provider: kind, cumulative: total, runDelta: delta, runDeltaAvailable: true }, "session", "turn");
state.execute.mockReset().mockImplementationOnce(async () => {
for (const observe of [accountingEvents.committed!, accountingEvents.duplicate!]) {
await observe({ eventType: "item.completed", turnId: "turn", payload: { kind: "usage", usage: notification.tokenUsage } } as unknown as PrpEvent);
await observe({ eventType: "turn.completed", turnId: "turn", payload: {} } as unknown as PrpEvent);
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: true, costUsd: 0.25,
costStatus: kind === "aws_agentcore" ? "estimated" : undefined,
usage: { inputTokens: 26, outputTokens: 8, cachedInputTokens: 13, cacheWriteTokens: 5 } }));
}
return { result: { summary: "cancelled" }, terminal: { runTerminalState: "cancelled" },
turnId: "turn", normalizedSessionId: "session", providerSessionId: null, driverKind: "test", driverVersion: "1", nativeEventCount: 2, highestContiguousSourceSeq: 2, usage: { total } };
});
const result = await executePaperclipNativeSession({ db: leaseDb(), execution: { ...execution, provider: { kind, model: "managed-model", managedProfile: { profileId: "managed-test" }, agentCoreProfile: { profileId: "agentcore-test" } } } as unknown as NativeExecutionInput, runnerInstanceId: "runner", onUsage });
expect(result).toMatchObject({ usageComplete: true, costUsd: 0.25, billingType: "metered_api", costStatus: kind === "aws_agentcore" ? "estimated" : undefined });
});
it.each([
{ provider: "codex", restart: false }, { provider: "codex", restart: true },
{ provider: "opencode", restart: false }, { provider: "opencode", restart: true },
] as const)("preserves earlier turns for $provider (restart: $restart)", async ({ provider, restart }) => {
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
const event = (eventType: string, turnId: string, sourceSeq: number, payload: object = {}) => ({
eventType, turnId, sourceSeq, sourceInstanceId: "provider", emittedAt: new Date().toISOString(), payload,
}) as unknown as PrpEvent;
const first = [event("turn.started", "first", 1), event("item.completed", "first", 2, { kind: "usage", usage: {
runDelta: { inputTokens: 10, cacheReadTokens: 3, cacheWriteTokens: 1, outputTokens: 2, providerCostUsd: 0.25 },
} }), event("turn.failed", "first", 3)];
const second = [event("turn.started", "second", 4), event("item.completed", "second", 5, { kind: "usage", usage: {
runDelta: { inputTokens: 20, cacheReadTokens: 6, cacheWriteTokens: 2, outputTokens: 4, providerCostUsd: 0.5 },
} }), event("turn.completed", "second", 6)];
state.execute.mockReset().mockImplementationOnce(async () => {
for (const entry of [...(restart ? [] : first), ...second]) await accountingEvents.committed!(entry);
for (const entry of provider === "opencode" ? [...first, ...second] : second) await accountingEvents.duplicate!(entry);
return { result: { summary: "cancelled" }, terminal: { runTerminalState: "cancelled" }, turnId: "second",
normalizedSessionId: "session", providerSessionId: null, driverKind: "test", driverVersion: "1", nativeEventCount: 6,
highestContiguousSourceSeq: 6, usage: null };
});
const result = await executePaperclipNativeSession({
db: leaseDb(execution, {}, {}, [], {}, "running", restart ? first : []),
execution: { ...execution, provider: { kind: provider, model: provider === "opencode" ? "openai/test" : "gpt-6-astra" } } as NativeExecutionInput,
runnerInstanceId: "runner", onUsage,
});
const usage = provider === "opencode"
? { inputTokens: 33, cachedInputTokens: 9, cacheWriteTokens: 3, outputTokens: 6 }
: { inputTokens: 14, cachedInputTokens: 6, cacheWriteTokens: 2, outputTokens: 4 };
expect(result).toMatchObject({ usageComplete: true, costUsd: provider === "opencode" ? 0.75 : 0.5, usage });
if (provider === "opencode") expect(result.costUsdExact).toBe("0.750000000");
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: true, costUsd: result.costUsd, usage }));
});
it.each(["missing_usage", "partial_usage", "missing_terminal", "unknown_price", "late_update"] as const)("keeps OpenCode retry accounting conservative (%s)", async scenario => {
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
let seq = 0;
const emit = (eventType: string, turnId: string, payload: object = {}) => accountingEvents.committed!({
eventType, turnId, sourceSeq: ++seq, sourceInstanceId: "provider", emittedAt: new Date().toISOString(), payload,
} as unknown as PrpEvent);
const usage = (inputTokens: number, outputTokens: number | undefined, providerCostUsd: number | undefined) => ({
kind: "usage", usage: { runDelta: { inputTokens, outputTokens, providerCostUsd } },
});
state.execute.mockReset().mockImplementationOnce(async () => {
await emit("turn.started", "first");
if (scenario !== "missing_usage") await emit("item.completed", "first",
usage(10, scenario === "partial_usage" ? undefined : 2, scenario === "unknown_price" ? undefined : 0.25));
if (scenario !== "missing_terminal") await emit("turn.failed", "first");
await emit("turn.started", "second");
expect(onUsage.mock.calls.at(-1)![0].complete).toBe(false);
await emit("item.completed", "second", usage(20, 4, 0.5));
await emit("turn.completed", "second");
if (scenario === "late_update") await emit("item.completed", "first", usage(12, 4, 0.35));
return { result: { summary: "cancelled" }, terminal: { runTerminalState: "cancelled" }, turnId: "second",
normalizedSessionId: "session", providerSessionId: null, driverKind: "test", driverVersion: "1", nativeEventCount: seq,
highestContiguousSourceSeq: seq, usage: null };
});
const result = await executePaperclipNativeSession({ db: leaseDb(),
execution: { ...execution, provider: { kind: "opencode", model: "openai/test" } } as NativeExecutionInput,
runnerInstanceId: "runner", onUsage });
const complete = ["unknown_price", "late_update"].includes(scenario);
const cost = ["missing_usage", "unknown_price"].includes(scenario) ? 0.5 : scenario === "late_update" ? 0.85 : 0.75;
expect(result).toMatchObject({ usageComplete: complete, costUsd: cost });
expect(result.costStatus).toBe(scenario === "late_update" ? undefined : "unpriced");
expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ complete, costUsd: cost, costStatus: result.costStatus });
if (scenario === "late_update") expect(result.usage).toMatchObject({ inputTokens: 32, outputTokens: 8 });
});
it.each(["committed", "duplicate"] as const)("saves completed usage before returning to result commitment (%s)", async mode => {
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
const failure = new Error("stopped after result commit, before executor returns");
state.execute.mockReset().mockImplementationOnce(async () => {
const observe = accountingEvents[mode]!;
await observe({ eventType: "item.completed", turnId: "turn", emittedAt: new Date().toISOString(), payload: { kind: "usage", usage: { runDelta: { inputTokens: 120, cacheReadTokens: 100, outputTokens: 10, providerCostUsd: 0.25 } } } } as unknown as PrpEvent);
await observe({ eventType: "turn.completed", turnId: "turn", emittedAt: new Date().toISOString(), payload: { status: "completed" } } as unknown as PrpEvent);
// The runtime cannot call completeRun until appendEvent has returned.
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: true, costUsd: 0.25, usage: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 } }));
throw failure;
});
await expect(executePaperclipNativeSession({ db: leaseDb(), execution: { ...execution, provider: { kind: "codex", model: "gpt-6-astra" } } as NativeExecutionInput, runnerInstanceId: "runner", onUsage })).rejects.toBe(failure);
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: true }));
});
it("clears provisional unpriced status when direct database persistence receives complete usage", async () => {
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = [];
state.execute.mockReset().mockImplementationOnce(async () => {
await accountingEvents.committed!({ eventType: "item.completed", turnId: "turn", emittedAt: new Date().toISOString(), payload: { kind: "usage", usage: { runDelta: { inputTokens: 10, outputTokens: 2, providerCostUsd: 0.25 } } } } as unknown as PrpEvent);
return { result: { summary: "cancelled" }, terminal: { runTerminalState: "cancelled" },
turnId: "turn", normalizedSessionId: "session", providerSessionId: null, driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
});
await executePaperclipNativeSession({ db: leaseDb(execution, {}, {}, updates), execution, runnerInstanceId: "runner" });
const dialect = new PgDialect();
const writes = updates.filter(update => update.values.usageJson).map(update =>
JSON.parse(dialect.sqlToQuery(update.values.usageJson as SQL).params[0] as string) as Record<string, unknown>);
expect(writes[0]).toMatchObject({ accountingReceiptReady: false, costStatus: "unpriced" });
// Apply the same JSON merge as the database: omitted keys retain the old value.
expect(Object.assign({}, ...writes)).toMatchObject({ accountingReceiptReady: true, costStatus: null, costUsd: 0.25 });
});
it.each(["stale_warm_zero", "partial", "fresh", "fresh_zero", "new_turn", "later_partial", "merged_partial"])("requires fresh complete usage at native completion (%s)", async scenario => {
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
const usage = { inputTokens: 120, cacheReadTokens: 100, outputTokens: 10, providerCostUsd: 0 };
const runDelta = scenario === "partial" ? { inputTokens: 120 } : scenario === "fresh_zero" ? { inputTokens: 0, outputTokens: 0 } : usage;
state.execute.mockReset().mockImplementationOnce(async () => {
if (scenario !== "stale_warm_zero") await accountingEvents.committed!({ eventType: "item.completed", turnId: "turn", emittedAt: new Date().toISOString(), payload: { kind: "usage", usage: { runDelta, runDeltaComplete: scenario !== "merged_partial" } } } as unknown as PrpEvent);
if (scenario === "new_turn") await accountingEvents.committed!({ eventType: "turn.started", turnId: "next-turn", emittedAt: new Date().toISOString(), payload: {} } as unknown as PrpEvent);
if (scenario === "later_partial") await accountingEvents.committed!({ eventType: "item.completed", turnId: "turn", emittedAt: new Date().toISOString(), payload: { kind: "usage", usage: { runDelta: { inputTokens: 130 } } } } as unknown as PrpEvent);
await accountingEvents.committed!({ eventType: "turn.completed", turnId: scenario === "new_turn" ? "next-turn" : "turn", emittedAt: new Date().toISOString(), payload: {} } as unknown as PrpEvent);
if (!["fresh", "fresh_zero"].includes(scenario)) expect(onUsage.mock.calls.every(([receipt]) => !receipt.complete)).toBe(true);
return { result: { summary: "cancelled" }, terminal: { runTerminalState: "cancelled" },
turnId: scenario === "new_turn" ? "next-turn" : "turn", normalizedSessionId: "session",
providerSessionId: null, driverKind: "test", driverVersion: "1", nativeEventCount: 1,
highestContiguousSourceSeq: 1,
usage: { runDelta: scenario === "stale_warm_zero" ? { inputTokens: 0, outputTokens: 0, providerCostUsd: 0 } : runDelta },
};
});
const result = await executePaperclipNativeSession({ db: leaseDb(), execution: { ...execution, provider: { kind: "codex", model: "gpt-6-astra" } } as NativeExecutionInput, runnerInstanceId: "runner", runnerEnvironment: { OPENAI_API_KEY: "fixture" }, onUsage });
const complete = ["fresh", "fresh_zero"].includes(scenario);
expect(result.usageComplete).toBe(complete);
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete }));
if (!complete) {
expect(result.costStatus).toBe("unpriced");
expect(result.costUsd).toBeNull();
expect(onUsage.mock.calls.at(-1)![0]).toMatchObject({ costUsd: null, costStatus: "unpriced" });
}
});
beforeEach(() => vi.spyOn(issueServiceModule, "issueService").mockReturnValue({ update: vi.fn(async () => ({ status: "blocked", statusVersion: 1 })) } as unknown as ReturnType<typeof issueServiceModule.issueService>));
afterEach(() => vi.restoreAllMocks());
it.each(["turn.failed", "turn.cancelled", "turn.interrupted", "missing_terminal", "session_total", "empty_usage", "different_turn"])("preserves run usage for %s without certifying incomplete evidence", async (scenario) => {
const onUsage = vi.fn(async (_receipt: import("@paperclipai/adapter-utils").AdapterUsageCheckpoint) => {});
const failure = new Error("provider_transport_failed: no semantic result");
state.execute.mockReset().mockImplementationOnce(async () => {
const usage = { inputTokens: 120, cacheReadTokens: 100, outputTokens: 10, providerCostUsd: 0.25 };
await accountingEvents.committed!({ eventType: "item.completed", turnId: "turn", emittedAt: new Date().toISOString(), payload: { kind: "usage", usage: scenario === "session_total" ? { total: usage } : { runDelta: scenario === "empty_usage" ? { requests: 1 } : usage } } } as unknown as PrpEvent);
if (scenario !== "missing_terminal") await accountingEvents.committed!({ eventType: scenario.startsWith("turn.") ? scenario : "turn.failed", turnId: scenario === "different_turn" ? "another-turn" : "turn", emittedAt: new Date().toISOString(), payload: { status: "failed" } } as unknown as PrpEvent);
throw failure;
});
await expect(executePaperclipNativeSession({ db: leaseDb(), execution: { ...execution, provider: { kind: "codex", model: "gpt-6-astra" } } as NativeExecutionInput, runnerInstanceId: "runner", onUsage })).rejects.toBe(failure);
if (scenario === "session_total") expect(onUsage).not.toHaveBeenCalled();
else if (scenario === "empty_usage") expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: false, usage: undefined, costUsd: null, costStatus: "unpriced" }));
else {
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: scenario.startsWith("turn."), costUsd: 0.25, usageBasis: "per_run", usage: { inputTokens: 20, cachedInputTokens: 100, outputTokens: 10 } }));
expect(onUsage.mock.calls[0]?.[0]).toMatchObject({ complete: false });
}
});
});
describe("native resumed preparation timing", () => {
it("keeps answered-question ingress at the run root rather than charging it to preparation", async () => {
const answeredAtMs = Date.now();
@@ -11,6 +11,7 @@ import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js";
import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server";
import { inferOpenAiCompatibleBiller, type AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
import {
isSupportedRemoteCodexVersion,
@@ -65,7 +66,7 @@ import type {
AdapterExecutionResult,
AdapterRuntimeEvent,
} from "../../adapters/index.js";
import type { NativeFinalizationResult } from "@paperclipai/shared";
import { usdToUnits, type NativeFinalizationResult } from "@paperclipai/shared";
import type {
HarnessRuntimeRequestResolution,
NativeExecutionInput,
@@ -7320,6 +7321,7 @@ export async function executePaperclipNativeSession(input: {
collectStopped: () => Promise<void>;
};
/** Persist task-level continuity before a durable goal can outlive this run. */
onUsage?: (receipt: AdapterUsageCheckpoint) => Promise<void>;
onGoalCheckpoint?: (snapshot: PersistedNativeSession) => Promise<void>;
sessionGoalControl?: NativeSessionGoalControl | null;
resumeSessionGoalHeartbeat?: boolean;
@@ -7331,6 +7333,7 @@ export async function executePaperclipNativeSession(input: {
/** Private grant materialization; never a user-configured host path. */
managedAiCredentialHome?: string;
managedAiCredentialIdentity?: string;
billingIdentity?: Pick<AdapterExecutionResult, "provider" | "biller" | "billingType">;
runnerExecutionTarget?: AdapterExecutionTarget | null;
/** Resolved per-run authorization; not an independent instance setting. */
runnerIngressAuthorized?: boolean;
@@ -7912,6 +7915,76 @@ async function executePaperclipNativeSessionWithinScope(
binding: { ...input.execution.binding, normalizedSessionId: nativeSessionKey(input.execution), runnerSourceInstanceId: effectiveRunnerInstanceId },
resolve: resolveNativeRuntimeRequest,
});
let observedAccountingUsage: Record<string, unknown> | null = null;
let observedAccountingTurn: string | undefined;
let nativeAccountingComplete = false;
// OpenCode and ACPX report their current turn; Codex reports a cumulative run delta.
// Rebuild per-turn state from the durable event log when a controller resumes.
const turnAccounting = ["opencode", "acpx"].includes(input.execution.provider.kind) ? createNativeTurnAccounting(input.execution.provider) : null;
if (turnAccounting) {
const history = await input.db.select({ payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, input.execution.binding.companyId), eq(heartbeatRunEvents.runId, input.execution.binding.runId),
or(sql`${heartbeatRunEvents.payload}->'prpEvent'->'payload'->>'kind' = 'usage'`,
inArray(heartbeatRunEvents.eventType, ["turn.started", "turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"])),
)).orderBy(heartbeatRunEvents.seq);
for (const row of history) turnAccounting.observe(record(row.payload).prpEvent as PrpEvent);
}
const persistAccountingUsage = async (usage: Record<string, unknown> | null, complete: boolean) => {
const accountingUsage = normalizeNativeUsage(usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" });
const accountingCost = accountingUsage ? nativeUsageCostUsd(usage, input.execution.provider) : undefined;
complete = complete && accountingUsage !== undefined;
const costStatus = complete && usage?.accountingCostIncomplete !== true
? input.execution.provider.kind === "aws_agentcore" ? "estimated" as const : undefined
: "unpriced" as const;
const billing = resolveNativeBilling(input.execution.provider, input.runnerEnvironment, input.billingIdentity);
const costUsdExact = typeof usage?.accountingCostUsdExact === "string" ? usage.accountingCostUsdExact : undefined;
await input.onUsage?.({ usage: accountingUsage, ...billing, complete, usageBasis: "per_run", costUsdExact,
model: input.execution.provider.model ?? "unknown", costUsd: accountingCost ?? null, costStatus });
if (!input.onUsage) await input.db.update(heartbeatRuns).set({
costAccountingPending: true,
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({
...accountingUsage, ...billing, accountingReceiptReady: complete, model: input.execution.provider.model ?? "unknown",
costUsd: accountingCost ?? null, costUsdExact: costUsdExact ?? null, costStatus: costStatus ?? null, usageSource: "per_run",
})}::jsonb`,
}).where(and(eq(heartbeatRuns.id, input.execution.binding.runId), eq(heartbeatRuns.companyId, input.execution.binding.companyId), isNull(heartbeatRuns.costAccountedAt)));
};
// appendEvent awaits this receipt before the runtime may commit completeRun.
// Replayed events must rebuild it too after a stop between event and receipt.
const observeAccountingEvent = async (event: PrpEvent) => {
if (turnAccounting) {
if (!turnAccounting.observe(event)) return;
const snapshot = turnAccounting.snapshot();
observedAccountingUsage = snapshot.usage;
observedAccountingTurn = snapshot.turnId;
await persistAccountingUsage(snapshot.usage, snapshot.complete);
return;
}
const payload = record(event.payload);
const usage = record(payload.usage);
const delta = record(usage.runDelta);
if (event.eventType === "turn.started") {
observedAccountingTurn = undefined;
if (observedAccountingUsage) await persistAccountingUsage(observedAccountingUsage, false);
}
// A warm attachment seeds a zero snapshot before any provider report.
// Only a fresh run delta for this turn can close its accounting. A later
// malformed report invalidates completeness instead of retaining a
// smaller earlier total or falling back to cumulative session usage.
if (payload.kind === "usage") {
observedAccountingTurn = undefined;
if (Object.hasOwn(usage, "runDelta")) {
observedAccountingUsage = { runDelta: delta, runDeltaComplete: usage.runDeltaComplete };
if (normalizeNativeUsage(observedAccountingUsage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" })) {
observedAccountingTurn = event.turnId;
}
}
if (observedAccountingUsage) await persistAccountingUsage(observedAccountingUsage, false);
}
if (["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"].includes(event.eventType)
&& observedAccountingUsage && observedAccountingTurn !== undefined && event.turnId === observedAccountingTurn) {
await persistAccountingUsage(observedAccountingUsage, true);
}
};
let completedConversationReply: PrpEvent | null = null;
const controlPlane = new PaperclipControlPlanePort(
input.db,
@@ -7929,6 +8002,7 @@ async function executePaperclipNativeSessionWithinScope(
{
privateKeyPem: input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY,
onCommittedEvent: async (event) => {
await observeAccountingEvent(event);
await toolTrace.observe(event);
if (event.eventType === "item.completed" &&
record(event.payload).kind === "agentMessage" &&
@@ -8136,6 +8210,7 @@ async function executePaperclipNativeSessionWithinScope(
);
},
onDuplicateEvent: async (event) => {
await observeAccountingEvent(event);
// A crash can happen after the event commit but before its callback
// finishes. Recover only idempotent durable projections here; activity,
// publication, logging, trace, and metric effects remain committed-only.
@@ -8687,6 +8762,19 @@ async function executePaperclipNativeSessionWithinScope(
},
{ parentName: "task.run" },
);
// Persist provider accounting before any workspace/issue finalization. A
// detached controller or failed finalizer must not lose a completed turn.
// session.usage() may be an attachment baseline, a partial report, or a
// cumulative session total. Use the observed run delta, and require its
// turn to match the terminal result before certifying it as complete.
if (turnAccounting) {
const snapshot = turnAccounting.finish(native.turnId);
observedAccountingUsage = snapshot.usage;
nativeAccountingComplete = snapshot.complete && snapshot.turnId === native.turnId;
} else nativeAccountingComplete = observedAccountingUsage !== null
&& observedAccountingTurn !== undefined && observedAccountingTurn === native.turnId;
native = { ...native, usage: observedAccountingUsage };
await persistAccountingUsage(native.usage, nativeAccountingComplete);
try {
await completeManagedNativeCredentialTurn(managedCredentialSession);
} catch {
@@ -9325,10 +9413,16 @@ async function executePaperclipNativeSessionWithinScope(
summary: native.result.summary,
sessionId: native.normalizedSessionId,
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
provider: nativeUsageBiller(input.execution.provider),
...resolveNativeBilling(input.execution.provider, input.runnerEnvironment, input.billingIdentity),
model: input.execution.provider.model,
usage: normalizeNativeUsage(native.usage),
costUsd: nativeUsageCostUsd(native.usage, input.execution.provider),
usage: normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" }),
costUsd: normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" })
? nativeUsageCostUsd(native.usage, input.execution.provider) ?? null : null,
costUsdExact: typeof native.usage?.accountingCostUsdExact === "string" ? native.usage.accountingCostUsdExact : undefined,
costStatus: nativeAccountingComplete && native.usage?.accountingCostIncomplete !== true
? input.execution.provider.kind === "aws_agentcore" ? "estimated" : undefined
: "unpriced",
usageComplete: nativeAccountingComplete,
usageBasis: "per_run",
nativeFinalization: finalization,
};
@@ -9359,6 +9453,9 @@ function numericUsageField(
function nativeUsageMeasurement(usage: Record<string, unknown>) {
const nestedUsage = record(usage.usage);
// An explicit partial delta must not fall through to a full session total.
if (Object.hasOwn(usage, "runDelta")) return record(usage.runDelta);
if (Object.hasOwn(nestedUsage, "runDelta")) return record(nestedUsage.runDelta);
const candidates = [
record(usage.runDelta),
record(nestedUsage.runDelta),
@@ -9393,6 +9490,74 @@ export function nativeUsageBiller(provider: NativeExecutionInput["provider"]): s
return "openai";
}
function createNativeTurnAccounting(provider: NativeExecutionInput["provider"]) {
const turns = new Map<string, { usage: Record<string, unknown> | null; terminal: boolean }>();
const sequences = new Map<string, number>();
let currentTurn: string | undefined;
const terminalTypes = ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"];
const snapshot = () => {
if (turns.size === 0) return { usage: null, turnId: undefined, complete: false };
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
let cost = 0n, costKnown = false, costIncomplete = false, complete = true;
for (const [turnId, turn] of turns) {
const normalized = normalizeNativeUsage(turn.usage);
complete &&= turnId !== "unattributed" && turn.terminal && normalized !== undefined;
inputTokens += (normalized?.inputTokens ?? 0) - (normalized?.cacheWriteTokens ?? 0);
outputTokens += normalized?.outputTokens ?? 0;
cacheReadTokens += normalized?.cachedInputTokens ?? 0;
cacheWriteTokens += normalized?.cacheWriteTokens ?? 0;
const price = nativeUsageCostUsd(turn.usage, provider);
if (price === undefined) costIncomplete = true;
else { cost += usdToUnits(price); costKnown = true; }
}
// A free empty attempt contributes no price evidence for later paid work.
// Keep an incomplete zero subtotal absent so complete aggregate tokens can
// still receive an estimate. Preserve positive known spend and true zero totals.
const retainCost = costKnown && (!costIncomplete || cost > 0n);
const runDelta = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
...(retainCost ? { providerCostUsd: Number(cost) / 1_000_000_000 } : {}) };
const current = currentTurn ? turns.get(currentTurn) : undefined;
return { usage: { runDelta, accountingCostIncomplete: costIncomplete,
...(retainCost ? { accountingCostUsdExact: `${cost / 1_000_000_000n}.${String(cost % 1_000_000_000n).padStart(9, "0")}` } : {}) },
turnId: current && normalizeNativeUsage(current.usage) ? currentTurn : undefined, complete };
};
return {
observe(event: PrpEvent) {
if (!event) return false;
const payload = record(event.payload);
const terminal = terminalTypes.includes(event.eventType);
if (payload.kind !== "usage" && event.eventType !== "turn.started" && !terminal) return false;
// Historical replay can revisit an earlier report after restoration.
// Re-persist its aggregate, but never rewind or count that report twice.
if (event.sourceInstanceId && Number.isSafeInteger(event.sourceSeq)) {
if (event.sourceSeq <= (sequences.get(event.sourceInstanceId) ?? -1)) return true;
sequences.set(event.sourceInstanceId, event.sourceSeq);
}
const key = event.turnId ?? "unattributed";
const turn = turns.get(key) ?? { usage: null, terminal: false };
if (event.eventType === "turn.started") {
currentTurn = event.turnId;
turn.usage = null;
turn.terminal = false;
} else if (payload.kind === "usage" && (!currentTurn || !turns.has(key))) currentTurn = event.turnId;
if (payload.kind === "usage") {
const usage = record(payload.usage);
turn.usage = Object.hasOwn(usage, "runDelta")
? { runDelta: record(usage.runDelta), runDeltaComplete: usage.runDeltaComplete } : null;
}
if (terminal) turn.terminal = true;
turns.set(key, turn);
return true;
},
snapshot,
finish(turnId: string | null) {
const turn = turnId ? turns.get(turnId) : undefined;
if (turn) turn.terminal = true;
return snapshot();
},
};
}
export function nativeUsageCostUsd(
usage: Record<string, unknown> | null,
provider?: NativeExecutionInput["provider"],
@@ -9403,50 +9568,73 @@ export function nativeUsageCostUsd(
if (provider?.kind === "acpx" && ["cursor", "copilot", "pi"].includes(provider.agent)) return undefined;
if (!usage) return undefined;
const measurement = nativeUsageMeasurement(usage);
const direct =
numericUsageField(usage, [
"providerCostUsd",
"cacheAdjustedCostUsd",
"costUsd",
]) ??
numericUsageField(measurement, [
"providerCostUsd",
"cacheAdjustedCostUsd",
"costUsd",
]);
if (direct !== undefined) return direct;
const cost = record(usage.cost);
const hasRunDelta = Object.hasOwn(usage, "runDelta") || Object.hasOwn(record(usage.usage), "runDelta");
const direct = numericUsageField(measurement, ["cacheAdjustedCostUsd", "providerCostUsd", "costUsd"])
?? (hasRunDelta ? undefined : numericUsageField(usage, ["cacheAdjustedCostUsd", "providerCostUsd", "costUsd"]));
// PRP currently defaults an absent providerCostUsd to zero. That zero is
// not proof of a free request when the runtime observed token consumption.
if (direct !== undefined && direct > 0) return direct;
const normalized = normalizeNativeUsage(usage);
if (direct === 0 && normalized && Object.values(normalized).every(value => value === 0)) return 0;
const cost = record(hasRunDelta ? measurement.cost : usage.cost);
const currency =
typeof cost.currency === "string" ? cost.currency.toUpperCase() : "USD";
if (currency !== "USD") return undefined;
return numericUsageField(cost, ["amount", "total"]);
}
export function normalizeNativeUsage(usage: Record<string, unknown> | null) {
if (!usage) return undefined;
/** Billing identity follows the selected runtime and resolved credentials.
* Unknown authentication remains unknown; a provider name does not prove billing mode. */
export function resolveNativeBilling(provider: NativeExecutionInput["provider"], env: NodeJS.ProcessEnv = {}, identity?: Pick<AdapterExecutionResult, "provider" | "biller" | "billingType">): Pick<AdapterExecutionResult, "provider" | "biller" | "billingType"> {
if (identity) return identity.provider === "openai" && identity.billingType === "metered_api"
? { ...identity, biller: inferOpenAiCompatibleBiller(env, identity.biller ?? "unknown") }
: identity;
if (provider.kind === "claude_managed") return { provider: "anthropic", biller: "anthropic", billingType: "metered_api" };
if (provider.kind === "aws_agentcore") return { provider: "aws_agentcore", biller: "aws_agentcore", billingType: "metered_api" };
if (provider.kind === "acpx" && ["cursor", "copilot"].includes(provider.agent)) {
const biller = nativeUsageBiller(provider);
return { provider: biller, biller, billingType: "unknown" };
}
if (provider.kind === "acpx" && provider.agent === "claude") {
if (env.CLAUDE_CODE_USE_BEDROCK === "1") return { provider: "anthropic", biller: "aws_bedrock", billingType: "metered_api" };
return { provider: "anthropic", biller: "anthropic", billingType: env.ANTHROPIC_API_KEY ? "metered_api" : env.CLAUDE_CODE_OAUTH_TOKEN ? "subscription_included" : "unknown" };
}
if (provider.kind === "opencode" || (provider.kind === "acpx" && provider.agent === "pi")) {
const parts = (provider.model ?? "").split("/");
const biller = parts.length > 1 ? parts[0] : provider.kind === "acpx" ? nativeUsageBiller(provider) : "unknown";
const modelProvider = biller === "openrouter" && parts.length > 2 ? parts[1] : biller;
return { provider: modelProvider, biller, billingType: "unknown" };
}
if (provider.kind !== "codex" && !(provider.kind === "acpx" && provider.agent === "codex"))
return { provider: "unknown", biller: "unknown", billingType: "unknown" };
return { provider: "openai", biller: env.OPENAI_API_KEY ? inferOpenAiCompatibleBiller(env, "openai") : "unknown", billingType: env.OPENAI_API_KEY ? "metered_api" : "unknown" };
}
export function normalizeNativeUsage(usage: Record<string, unknown> | null, options: { inputIncludesCacheReads?: boolean } = {}) {
if (!usage || usage.runDeltaComplete === false || record(usage.usage).runDeltaComplete === false) return undefined;
const measurement = nativeUsageMeasurement(usage);
const cache = record(measurement.cache);
const cachedInputTokens =
numericUsageField(measurement, [
"cachedInputTokens",
"cacheReadInputTokens",
"cacheReadTokens",
"cachedReadTokens",
]) ?? numericUsageField(cache, ["read"]);
const count = (source: Record<string, unknown>, keys: string[]) => {
for (const key of keys) {
if (source[key] === undefined) continue;
const value = source[key];
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : NaN;
}
return undefined;
};
const input = count(measurement, ["inputTokens", "input", "promptTokens"]);
const outputTokens = count(measurement, ["outputTokens", "output", "completionTokens"]);
const cachedInputTokens = count(measurement, ["cachedInputTokens", "cacheReadInputTokens", "cacheReadTokens", "cachedReadTokens"]) ?? count(cache, ["read"]);
const cacheWriteTokens = count(measurement, ["cacheWriteTokens", "cacheCreationInputTokens", "cachedWriteTokens"]) ?? count(cache, ["write"]);
if (input === undefined || outputTokens === undefined
|| ![input, outputTokens, cachedInputTokens ?? 0, cacheWriteTokens ?? 0].every(Number.isSafeInteger)) return undefined;
const inputTokens = input - (options.inputIncludesCacheReads ? cachedInputTokens ?? 0 : 0)
+ (options.inputIncludesCacheReads ? 0 : cacheWriteTokens ?? 0);
if (!Number.isSafeInteger(inputTokens) || inputTokens < (cacheWriteTokens ?? 0)) return undefined;
return {
inputTokens:
numericUsageField(measurement, [
"inputTokens",
"input",
"promptTokens",
]) ?? 0,
outputTokens:
numericUsageField(measurement, [
"outputTokens",
"output",
"completionTokens",
]) ?? 0,
inputTokens, outputTokens,
...(cachedInputTokens === undefined ? {} : { cachedInputTokens }),
...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }),
};
}
+3 -1
View File
@@ -337,7 +337,7 @@ export function pluginManagedAgentService(
// and its canonical history. Transfer only its proven stable-key marker to
// the replacement installation, atomically with the new scoped bindings.
await upsertBinding(companyId, declaration, agentId, {}, adapterType, tx);
const [relinked] = await tx.update(agents).set({
await tx.update(agents).set({
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, agent.metadata),
updatedAt: new Date(),
}).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).returning();
@@ -346,6 +346,8 @@ export function pluginManagedAgentService(
action: "plugin.managed_agent.relinked", entityType: "agent", entityId: agentId,
details: { sourcePluginKey: options.pluginKey, managedResourceKey: declaration.agentKey, previousPluginId },
});
const relinked = await agentService(tx as unknown as Db).getById(agentId);
if (!relinked) throw notFound("Managed agent not found after relink");
return relinked as Agent;
});
}
@@ -0,0 +1,376 @@
import { z } from "zod";
import { and, eq, gte, inArray, lt } from "drizzle-orm";
import {
companySecrets,
providerBillingSnapshots,
type Db,
} from "@paperclipai/db";
import {
centsToUnits,
usdToUnits,
unitsToCents,
importProviderCostsSchema,
type ImportProviderCosts,
} from "@paperclipai/shared";
import { secretService } from "./secrets.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { createFinanceEventInTransaction } from "./finance.js";
import { forbidden, unprocessable } from "../errors.js";
const pageSchema = z.object({
data: z
.array(
z.object({
start_time: z.number().int().optional(),
end_time: z.number().int().optional(),
starting_at: z.iso.datetime({ offset: true }).optional(),
ending_at: z.iso.datetime({ offset: true }).optional(),
results: z
.array(
z.object({
project_id: z.string().nullable().optional(),
workspace_id: z.string().nullable().optional(),
amount: z.union([
z.string(),
z.object({ value: z.number().finite(), currency: z.string() }),
]),
currency: z.string().optional(),
}),
)
.max(5000),
}),
)
.max(31),
has_more: z.boolean(),
next_page: z.string().max(4000).nullable().optional(),
});
export interface DailyProviderCost {
day: string;
scopeId: string;
amountCents: string;
}
async function boundedJson(response: Response) {
if (!response.ok || !response.body)
throw unprocessable(
"Provider billing is unavailable. Check the admin credential and selected account.",
);
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
size += next.value.byteLength;
if (size > 2_000_000)
throw unprocessable(
"Provider billing response is too large; use a shorter period.",
);
chunks.push(next.value);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} finally {
await reader.cancel().catch(() => undefined);
}
}
/** Complete every page before writing. Group only by project/workspace so a
* provider's line-item changes cannot manufacture duplicate charges. */
export async function fetchProviderDailyCosts(
input: ImportProviderCosts,
credential: string,
fetcher = fetch,
): Promise<DailyProviderCost[]> {
const parsed = importProviderCostsSchema.parse(input);
const from = Date.parse(parsed.from),
to = Date.parse(parsed.to);
const base = new URL(
parsed.provider === "openai"
? "https://api.openai.com/v1/organization/costs"
: "https://api.anthropic.com/v1/organizations/cost_report",
);
base.searchParams.set(
parsed.provider === "openai" ? "start_time" : "starting_at",
parsed.provider === "openai"
? String(from / 1000)
: new Date(from).toISOString(),
);
base.searchParams.set(
parsed.provider === "openai" ? "end_time" : "ending_at",
parsed.provider === "openai"
? String(to / 1000)
: new Date(to).toISOString(),
);
base.searchParams.set("bucket_width", "1d");
base.searchParams.set("limit", "31");
base.searchParams.append(
"group_by[]",
parsed.provider === "openai" ? "project_id" : "workspace_id",
);
const headers: Record<string, string> =
parsed.provider === "openai"
? {
Authorization: `Bearer ${credential}`,
"OpenAI-Organization": parsed.accountId,
}
: { "x-api-key": credential, "anthropic-version": "2023-06-01" };
headers["User-Agent"] = "Paperclip/1.0 (https://paperclip.ing)";
const totals = new Map<string, bigint>();
const seen = new Set<string>();
const days = new Set<number>();
// Missing requested scopes in an otherwise complete daily report mean zero.
for (let time = from; time < to; time += 86400000)
for (const scope of parsed.scopeIds)
totals.set(`${new Date(time).toISOString().slice(0, 10)}:${scope}`, 0n);
const cursors = new Set<string>();
const signal = AbortSignal.timeout(30_000);
if (parsed.provider === "anthropic") {
const identity = z.object({ id: z.string() }).parse(
await boundedJson(
await fetcher("https://api.anthropic.com/v1/organizations/me", {
headers,
signal,
redirect: "error",
}),
),
);
if (identity.id !== parsed.accountId)
throw unprocessable(
"The admin credential belongs to a different provider organization.",
);
}
for (let page = 0; page < 100; page++) {
const response = await fetcher(base, {
headers,
signal,
redirect: "error",
});
const body = pageSchema.parse(await boundedJson(response));
for (const bucket of body.data) {
const start =
parsed.provider === "openai"
? (bucket.start_time ?? NaN) * 1000
: Date.parse(bucket.starting_at ?? "");
const end =
parsed.provider === "openai"
? (bucket.end_time ?? NaN) * 1000
: Date.parse(bucket.ending_at ?? "");
if (
!Number.isFinite(start) ||
start < from ||
end > to ||
end - start !== 86400000 ||
start % 86400000 !== 0
)
throw unprocessable("Provider returned an unexpected billing period.");
days.add(start);
for (const result of bucket.results) {
const scopeField = parsed.provider === "openai" ? "project_id" : "workspace_id";
if (!Object.hasOwn(result, scopeField)) {
throw unprocessable("Provider report is missing the requested project/workspace grouping.");
}
const scope =
(parsed.provider === "openai"
? result.project_id
: result.workspace_id) ?? "default";
if (!parsed.scopeIds.includes(scope)) continue;
const identity = `${new Date(start).toISOString().slice(0, 10)}:${scope}`;
if (seen.has(identity))
throw unprocessable("Provider returned overlapping billing buckets.");
seen.add(identity);
const currency =
typeof result.amount === "object"
? result.amount.currency
: result.currency;
if (currency?.toUpperCase() !== "USD")
throw unprocessable("Only USD provider cost reports are supported.");
const units =
parsed.provider === "openai" && typeof result.amount === "object"
? usdToUnits(result.amount.value)
: parsed.provider === "anthropic" &&
typeof result.amount === "string"
? centsToUnits(result.amount)
: -1n;
if (units < 0n)
throw unprocessable("Provider returned an invalid billing amount.");
totals.set(identity, units);
}
}
if (!body.has_more) {
if (days.size !== (to - from) / 86400000)
throw unprocessable(
"Provider report is incomplete; no financial events were changed.",
);
return [...totals].map(([key, value]) => ({
day: key.slice(0, 10),
scopeId: key.slice(11),
amountCents: unitsToCents(value),
}));
}
if (!body.next_page || cursors.has(body.next_page))
throw unprocessable("Provider returned an invalid billing cursor.");
cursors.add(body.next_page);
base.searchParams.set("page", body.next_page);
}
throw unprocessable("Provider billing report exceeded the page limit.");
}
export async function importProviderDailyCosts(
db: Db,
companyId: string,
raw: ImportProviderCosts,
actorId: string,
fetcher = fetch,
) {
const input = importProviderCostsSchema.parse(raw);
const [secret] = await db
.select()
.from(companySecrets)
.where(
and(
eq(companySecrets.id, input.secretId),
eq(companySecrets.companyId, companyId),
),
);
if (!secret || secret.scope !== "company")
throw forbidden("Select a company billing credential.");
// Metadata is an explicit operator designation, not the secret-storage
// provider (which may be local encryption, AWS, etc.). Never send a generic
// company secret to an external API merely because the caller knows its ID.
const designation = secret.providerMetadata?.providerBilling;
if (!designation || typeof designation !== "object" || Array.isArray(designation)
|| !("provider" in designation) || designation.provider !== input.provider
|| !("accountId" in designation) || designation.accountId !== input.accountId) {
throw forbidden("Select a company secret designated for this billing provider and account.");
}
const credential = await secretService(db).resolveSecretValue(
companyId,
secret.id,
"latest",
{
accessContext: {
consumerType: "system",
consumerId: "provider-billing-import",
actorType: "user",
actorId,
},
},
);
// Compare database revisions, not application clocks: concurrent importers
// can run on different hosts or start in the same millisecond.
const snapshots = await db
.select()
.from(providerBillingSnapshots)
.where(
and(
eq(providerBillingSnapshots.companyId, companyId),
eq(providerBillingSnapshots.provider, input.provider),
eq(providerBillingSnapshots.accountId, input.accountId),
inArray(providerBillingSnapshots.scopeId, input.scopeIds),
gte(providerBillingSnapshots.day, input.from),
lt(providerBillingSnapshots.day, input.to),
),
);
const expectedRevisions = new Map(
snapshots.map((row) => [`${row.day}:${row.scopeId}`, row.revision]),
);
const daily = await fetchProviderDailyCosts(input, credential, fetcher);
return applyProviderDailyCosts(
db,
companyId,
input,
daily,
actorId,
expectedRevisions,
);
}
export async function applyProviderDailyCosts(
db: Db,
companyId: string,
input: ImportProviderCosts,
daily: DailyProviderCost[],
actorId: string,
expectedRevisions?: ReadonlyMap<string, number>,
) {
return withAccountingTransaction(db, companyId, async (tx, publications) => {
let eventsCreated = 0;
for (const day of daily) {
const where = and(
eq(providerBillingSnapshots.companyId, companyId),
eq(providerBillingSnapshots.provider, input.provider),
eq(providerBillingSnapshots.accountId, input.accountId),
eq(providerBillingSnapshots.scopeId, day.scopeId),
eq(providerBillingSnapshots.day, day.day),
);
const [previous] = await tx
.select()
.from(providerBillingSnapshots)
.where(where);
const delta =
centsToUnits(day.amountCents) -
centsToUnits(previous?.amountCents ?? "0");
if (previous && delta === 0n) continue;
if (
expectedRevisions &&
(previous?.revision ?? 0) !==
(expectedRevisions.get(`${day.day}:${day.scopeId}`) ?? 0)
)
throw unprocessable(
"A newer provider report was imported. Fetch the report again.",
);
const revision = (previous?.revision ?? 0) + 1;
const [snapshot] = previous
? await tx
.update(providerBillingSnapshots)
.set({
amountCents: day.amountCents,
revision,
updatedAt: new Date(),
})
.where(where)
.returning()
: await tx
.insert(providerBillingSnapshots)
.values({
companyId,
provider: input.provider,
accountId: input.accountId,
...day,
revision,
})
.returning();
if (!delta) continue;
await createFinanceEventInTransaction(
tx,
publications,
companyId,
{
idempotencyKey: `provider-report:${snapshot.id}:${revision}`,
biller: input.provider,
eventKind: "inference_charge",
direction: delta < 0n ? "credit" : "debit",
currency: "USD",
amountCents: unitsToCents(delta < 0n ? -delta : delta),
occurredAt: new Date(`${day.day}T00:00:00.000Z`),
description:
`${input.provider} cost report · ${day.scopeId} · ${day.day}${revision > 1 ? " (correction)" : ""}`.slice(
0,
500,
),
metadataJson: {
source: "provider_cost_report",
accountId: input.accountId,
scopeId: day.scopeId,
revision,
totalCents: day.amountCents,
},
},
{ actorType: "user", actorId },
);
eventsCreated++;
}
return { daysRead: daily.length, eventsCreated };
});
}
@@ -0,0 +1,12 @@
import { createHash } from "node:crypto";
function canonical(value: unknown): unknown {
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)]));
return value;
}
export function receiptFingerprint(value: unknown): string {
return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
}
+6
View File
@@ -3835,6 +3835,12 @@ export function recoveryService(
current.companyId,
current.id,
);
// Budget admission may wait behind another recovery worker. Read the
// execution paths after the persisted counter so a newly reserved
// successor cannot be mistaken for permission to schedule the next one.
const currentState = await collectDispositionRepairSourceState(db, { issue: current });
if (currentState.hasActiveExecutionPath || currentState.hasDurableWaitingPath) return "skipped";
if (!episode && currentState.fingerprint !== state.fingerprint) return "skipped";
const runAttempt =
previousAttempt?.fingerprint === state.fingerprint
? previousAttempt.attempt
+196
View File
@@ -0,0 +1,196 @@
import { usdToUnits, unitsToCents } from "@paperclipai/shared";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { accountingRuntimeBaselines, budgetReservations, agentRuntimeState, agents, costEvents, heartbeatRuns, nativeRunFinalizations, issues, projects, type Db } from "@paperclipai/db";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { conflict } from "../errors.js";
import { createCostEventInTransaction } from "./costs.js";
import { budgetService, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
import { logger } from "../middleware/logger.js";
import { promises as fs } from "node:fs";
import { replayUsageReceipts, indexPendingUsageReceipts, recoverPendingRunUsageReceipts, type UsageReceiptIndex } from "./usage-receipts.js";
const terminalStatuses = ["succeeded", "failed", "timed_out", "cancelled", "interrupted"];
const object = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const text = (value: unknown) => typeof value === "string" && value.length > 0 ? value : null;
const amount = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
/** The run's persisted usage is a durable receipt. Ledger, runtime totals, and
* acknowledgement commit together, including after a restart or failed run. */
export async function accountRunCost(db: Db, runId: string, hooks: BudgetServiceHooks = {}, receiptIndex?: UsageReceiptIndex) {
const [identity] = await db.select({ companyId: heartbeatRuns.companyId, status: heartbeatRuns.status,
pending: heartbeatRuns.costAccountingPending, accountedAt: heartbeatRuns.costAccountedAt,
issueId: sql<string | null>`${heartbeatRuns.usageJson}->'ledgerScope'->>'issueId'`,
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
if (!identity) return false;
// Finalization can call twice. Avoid disk work for settled or active runs,
// but retain the locked recheck, stale-marker cleanup, and stop delivery.
const index = identity.pending && !identity.accountedAt && terminalStatuses.includes(identity.status)
? receiptIndex ?? await indexPendingUsageReceipts() : undefined;
let recoveredFiles: string[] = [];
const accounted = await withAccountingTransaction(db, identity.companyId, async (tx, publications) => {
// Native recovery owns the issue before updating its run. Acquire the
// charge's issue FK lock first so inserting it cannot reverse that order.
const issueId = text(identity.issueId);
if (issueId) await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, identity.companyId), eq(issues.id, issueId),
)).for("key share");
let [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).for("update");
if (!run?.costAccountingPending || !terminalStatuses.includes(run.status)) return false;
if (run.costAccountedAt) {
await tx.update(heartbeatRuns).set({ costAccountingPending: false }).where(eq(heartbeatRuns.id, run.id));
return false;
}
// The bounded global replay can leave a newer receipt behind an older
// complete snapshot. Drain this run under the accounting lock before
// deciding completeness, and retain disk evidence until the outer commit.
recoveredFiles = await recoverPendingRunUsageReceipts(tx, { companyId: identity.companyId, runId }, undefined, { retainFiles: true, index });
[run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
if (text(object(object(run.usageJson).ledgerScope).issueId) !== issueId) {
throw conflict("Run cost attribution changed. Retry accounting.");
}
const preProviderFailure = object(object(run.resultJson).executionRecovery).providerWorkStarted === false
|| object(run.usageJson).accountingProviderWorkStarted === false;
// A failed native turn is not necessarily the end of this heartbeat: the
// coordinator can resume the same run. Only its closed provider boundary
// authorizes acknowledgement; finalization may still run after a result.
if (run.runtimeMode === "native" && !preProviderFailure) {
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id));
if (coordinator && !coordinator.resultId && coordinator.phase !== "terminal_failure") return false;
}
// A stop can mark the run terminal before the provider's shutdown receipt
// arrives. Keep the debt pending instead of acknowledging an invented zero
// and permanently discarding that late receipt.
if (!preProviderFailure && object(run.usageJson).accountingReceiptReady === false) return false;
// Acknowledgement is part of the same transaction. Budget checks inside
// this transaction must not mistake this receipt for missing accounting.
await tx.update(heartbeatRuns).set({ costAccountingPending: false, costAccountedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
await tx.update(budgetReservations).set({ state: preProviderFailure ? "released" : "settled", settledAt: new Date() })
.where(and(eq(budgetReservations.companyId, run.companyId), eq(budgetReservations.runId, run.id), eq(budgetReservations.state, "held")));
if (preProviderFailure) return true;
const usage = object(run.usageJson);
const scope = object(usage.ledgerScope);
// Deleted attribution targets become unallocated; a deletion must never
// prevent recording provider spend. Foreign-company references still fail.
for (const [table, key] of [[issues, "issueId"], [projects, "projectId"]] as const) {
const id = text(scope[key]);
if (id) {
const [linked] = await tx.select({ id: table.id }).from(table).where(eq(table.id, id));
if (!linked) scope[key] = null;
}
}
const costUsd = amount(usage.cacheAdjustedCostUsd) ?? text(usage.costUsdExact) ?? amount(usage.costUsd);
const billingType = text(usage.billingType) ?? "unknown";
const costUnits = billingType === "subscription_included" ? 0n : usdToUnits(costUsd ?? 0);
const costCents = unitsToCents(costUnits);
const [agent] = await tx.select({ adapterType: agents.adapterType }).from(agents).where(eq(agents.id, run.agentId));
const hasBillableEvidence = agent?.adapterType !== "process" || costUsd !== null || usage.costStatus === "unpriced"
|| ["inputTokens", "cachedInputTokens", "outputTokens"].some(key => (amount(usage[key]) ?? 0) > 0)
|| (text(usage.provider) !== null && usage.provider !== "unknown");
const receipt = {
idempotencyKey: `heartbeat:${run.id}:final`,
agentId: run.agentId, heartbeatRunId: run.id,
issueId: text(scope.issueId), projectId: text(scope.projectId), billingCode: text(scope.billingCode),
provider: text(usage.provider) ?? "unknown", biller: text(usage.biller) ?? text(usage.provider) ?? "unknown",
providerRequestId: text(usage.providerRequestId),
pricingProvenance: object(usage.pricingProvenance),
model: text(usage.model) ?? "unknown", billingType,
costStatus: (costUsd === null || usage.costStatus === "unpriced") && billingType !== "subscription_included" && hasBillableEvidence ? "unpriced" : usage.costStatus === "estimated" ? "estimated" : "reported",
inputTokens: amount(usage.inputTokens) ?? 0, cachedInputTokens: amount(usage.cachedInputTokens) ?? 0, outputTokens: amount(usage.outputTokens) ?? 0,
costCents, occurredAt: run.finishedAt ?? run.createdAt,
};
const parts = Array.isArray(usage.usageByModel) ? usage.usageByModel.map(object) : [];
const tokensMatch = ["inputTokens", "cachedInputTokens", "outputTokens"].every((key) =>
parts.reduce((sum, part) => sum + (amount(object(part.usage)[key]) ?? 0), 0) === (amount(usage[key]) ?? 0));
const costMatches = costUsd !== null && parts.reduce((sum, part) => sum + usdToUnits(amount(part.costUsd) ?? 0), 0n) === usdToUnits(costUsd);
const validParts = parts.length > 0 && tokensMatch && costMatches
&& parts.every((part) => text(part.model) && amount(part.costUsd) !== null)
&& new Set(parts.map((part) => part.model)).size === parts.length;
if (validParts) {
let allocated = 0n;
for (const [index, part] of parts.entries()) {
const partUsage = object(part.usage);
const remainder = costUnits - allocated;
const reported = usdToUnits(amount(part.costUsd) ?? 0);
const partUnits = billingType === "subscription_included" ? 0n : index === parts.length - 1 ? remainder : reported < remainder ? reported : remainder;
allocated += partUnits;
const partCents = unitsToCents(partUnits);
await createCostEventInTransaction(tx, run.companyId, {
...receipt, idempotencyKey: `heartbeat:${run.id}:model:${index}`, model: text(part.model)!,
inputTokens: amount(partUsage.inputTokens) ?? 0, cachedInputTokens: amount(partUsage.cachedInputTokens) ?? 0,
outputTokens: amount(partUsage.outputTokens) ?? 0, costCents: partCents,
}, publications);
}
} else {
await createCostEventInTransaction(tx, run.companyId, receipt, publications);
}
const [event] = await tx.select({
inputTokens: sql<string>`coalesce(sum(${costEvents.inputTokens}),0)::text`,
cachedInputTokens: sql<string>`coalesce(sum(${costEvents.cachedInputTokens}),0)::text`,
outputTokens: sql<string>`coalesce(sum(${costEvents.outputTokens}),0)::text`,
costCents: sql<string>`coalesce(sum(${costEvents.costCents}),0)::text`,
}).from(costEvents).where(and(eq(costEvents.companyId, run.companyId), eq(costEvents.heartbeatRunId, run.id)));
await tx.insert(agentRuntimeState).values({ agentId: run.agentId, companyId: run.companyId, adapterType: agent.adapterType }).onConflictDoNothing();
await tx.execute(sql`insert into accounting_runtime_baselines (agent_id,company_id,cost_cents,input_tokens,cached_input_tokens,output_tokens)
select agent_id,company_id,total_cost_cents,total_input_tokens,total_cached_input_tokens,total_output_tokens
from agent_runtime_state where agent_id = ${run.agentId} on conflict do nothing`);
await tx.update(agentRuntimeState).set({
totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${event.inputTokens}`,
totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${event.cachedInputTokens}`,
totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${event.outputTokens}`,
totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${event.costCents}::numeric`,
updatedAt: new Date(),
}).where(eq(agentRuntimeState.agentId, run.agentId));
await tx.update(heartbeatRuns).set({ costAccountingPending: false, costAccountedAt: new Date(), accountingProjectionVersion: "v2", accountingLastError: null, accountingLastAttemptAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
return true;
});
for (const file of recoveredFiles) {
try { await fs.rm(file, { force: true }); }
catch (error) { logger.warn({ err: error, runId }, "Accounting committed; recovered receipt cleanup will retry"); }
}
await deliverBudgetEnforcement(db, hooks, identity.companyId);
return accounted;
}
export async function reconcileRunCosts(db: Db, hooks: BudgetServiceHooks = {}) {
const pending = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.costAccountingPending, true), inArray(heartbeatRuns.status, terminalStatuses),
sql`(${heartbeatRuns.resultJson}->'executionRecovery'->>'providerWorkStarted' = 'false'
or ${heartbeatRuns.usageJson}->>'accountingProviderWorkStarted' = 'false'
or not exists (select 1 from ${nativeRunFinalizations} where ${nativeRunFinalizations.runId} = ${heartbeatRuns.id}
and ${heartbeatRuns.runtimeMode} = 'native' and ${nativeRunFinalizations.resultId} is null
and ${nativeRunFinalizations.phase} <> 'terminal_failure'))`,
sql`(${heartbeatRuns.usageJson}->>'accountingReceiptReady' is distinct from 'false' or ${heartbeatRuns.resultJson}->'executionRecovery'->>'providerWorkStarted' = 'false' or ${heartbeatRuns.usageJson}->>'accountingProviderWorkStarted' = 'false')`,
)).orderBy(asc(heartbeatRuns.updatedAt), asc(heartbeatRuns.id)).limit(100);
let accounted = 0;
const receiptIndex = pending.length > 0 ? await indexPendingUsageReceipts() : undefined;
for (const run of pending) {
try { if (await accountRunCost(db, run.id, hooks, receiptIndex)) accounted++; }
catch (error) {
// Move failed attempts behind other pending work so a poisoned receipt
// cannot monopolize the bounded batch on every recovery tick.
await db.update(heartbeatRuns).set({ updatedAt: new Date(), accountingLastAttemptAt: new Date(), accountingAttemptCount: sql`${heartbeatRuns.accountingAttemptCount} + 1`, accountingLastError: error instanceof Error ? error.message.slice(0, 1000) : "Accounting failed" }).where(eq(heartbeatRuns.id, run.id));
logger.error({ err: error, runId: run.id }, "Run accounting remains pending for retry");
}
}
await budgetService(db, hooks).reconcilePolicies();
return { scanned: pending.length, accounted };
}
/** Share one full sweep across overlapping scheduler ticks. Do not enqueue
* another sweep: the next tick retries after either success or failure. */
export function createCostAccountingReconciler(db: Db, hooks: BudgetServiceHooks = {}) {
let inFlight: Promise<Awaited<ReturnType<typeof reconcileRunCosts>>> | undefined;
return () => {
if (!inFlight) {
inFlight = (async () => {
try {
await replayUsageReceipts(db);
return await reconcileRunCosts(db, hooks);
} finally {
inFlight = undefined;
}
})();
}
return inFlight;
};
}
+14 -2
View File
@@ -165,6 +165,18 @@ async function setTreeMode(directory: string, readonly: boolean): Promise<void>
if (readonly) await fs.chmod(directory, 0o555);
}
// Darwin requires a directory being renamed to be writable by its owner.
// Change only the directory itself, while the publication lock is held; the
// contents remain read-only. Restore the mode even when publication fails.
async function renameCacheDirectory(source: string, destination: string) {
const stat = await fs.lstat(source);
const directory = stat.isDirectory() && !stat.isSymbolicLink();
if (directory) await fs.chmod(source, 0o700);
let moved = false;
try { await fs.rename(source, destination); moved = true; }
finally { if (directory) await fs.chmod(moved ? destination : source, stat.mode & 0o777); }
}
async function removeTree(directory: string): Promise<void> {
await setTreeMode(directory, false);
await fs.rm(directory, { recursive: true, force: true });
@@ -202,9 +214,9 @@ export async function resolveRuntimeSkillCache(
if (!await matches(spec, staging)) throw new Error("Runtime skill cache validation failed");
// Lifecycle mutations can update the DB while this builder owns the filesystem lock.
if (!await stillInstalled()) throw new Error("Skill was renamed or removed during preparation");
await fs.rename(spec.entry, path.join(spec.root, `.invalid-${spec.fingerprint}-${randomUUID()}`))
await renameCacheDirectory(spec.entry, path.join(spec.root, `.invalid-${spec.fingerprint}-${randomUUID()}`))
.catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; });
await fs.rename(staging, spec.entry);
await renameCacheDirectory(staging, spec.entry);
return path.join(spec.entry, "files");
} finally { await removeTree(staging); }
});
+3 -3
View File
@@ -179,7 +179,7 @@ export function statusCardService(
: Promise.resolve(null),
db.select({
tokens: sql<number>`coalesce(sum(coalesce(${statusCardUpdates.inputTokens}, 0) + coalesce(${statusCardUpdates.outputTokens}, 0)), 0)::int`,
costCents: sql<number>`coalesce(sum(${statusCardUpdates.costCents}), 0)::int`,
costCents: sql<number>`coalesce(sum(${statusCardUpdates.costCents}), 0)::double precision`,
})
.from(statusCardUpdates)
.where(and(eq(statusCardUpdates.cardId, card.id), gte(statusCardUpdates.startedAt, dayStart)))
@@ -877,7 +877,7 @@ export function statusCardService(
? await tx.select({
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
costCents: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
}).from(costEvents).where(eq(costEvents.heartbeatRunId, actor.runId))
: [];
const existingUpdate = await tx.select().from(statusCardUpdates)
@@ -892,7 +892,7 @@ export function statusCardService(
changeSummary: input.changeSummary,
inputTokens: Number(usage[0]?.inputTokens ?? 0),
outputTokens: Number(usage[0]?.outputTokens ?? 0),
costCents: Number(usage[0]?.costCents ?? 0),
costCents: sql`${usage[0]?.costCents ?? "0"}::numeric`,
};
if (existingUpdate) {
await tx.update(statusCardUpdates).set(updateValues).where(eq(statusCardUpdates.id, existingUpdate.id));
+289
View File
@@ -0,0 +1,289 @@
import { priceCodexReceipt } from "./codex-pricing.js";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { and, eq, isNull, sql } from "drizzle-orm";
import { heartbeatRuns, runUsageReceipts, type Db } from "@paperclipai/db";
import type { AdapterExecutionResult, AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
import { pricingProvenanceSchema, usdToUnits } from "@paperclipai/shared";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { conflict, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { receiptFingerprint } from "./receipt-fingerprint.js";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { ensureDurableDirectory, syncDirectory } from "../lib/durable-directory.js";
const token = z.number().int().nonnegative().max(2_147_483_647);
const price = z.number().finite().nonnegative().nullable().optional();
const usage = z.object({ inputTokens: token, outputTokens: token, cachedInputTokens: token.optional(), cacheWriteTokens: token.optional() });
const checkpointSchema = z.object({
attemptId: z.string().uuid().optional(), usage: usage.optional(),
usageByModel: z.array(z.object({ model: z.string().min(1).max(250), usage, costUsd: z.number().finite().nonnegative() })).max(500).optional(),
usageBasis: z.enum(["per_run", "session_cumulative"]).nullable().optional(),
provider: z.string().max(250).nullable().optional(), biller: z.string().max(250).nullable().optional(),
model: z.string().max(250).nullable().optional(), billingType: z.string().max(100).nullable().optional(),
costUsd: price, cacheAdjustedCostUsd: price,
costStatus: z.enum(["reported", "estimated", "unpriced"]).optional(),
pricingContext: z.object({ serviceTier: z.string().max(50).optional(), contextTier: z.enum(["short", "long"]).optional() }).optional(),
pricingProvenance: pricingProvenanceSchema.optional(),
costUsdExact: z.string().max(160).refine(value => { try { return !value.startsWith("-") && usdToUnits(value) >= 0n; } catch { return false; } }, "Invalid exact USD amount").nullable().optional(),
providerRequestId: z.string().max(250).nullable().optional(), complete: z.boolean(),
});
const envelopeSchema = z.object({
schema: z.literal("paperclip/accounting-receipt/v1"), id: z.string().uuid(), companyId: z.string().uuid(),
runId: z.string().uuid(), sourceId: z.string().uuid(), sequence: z.number().int().positive(), receivedAt: z.iso.datetime(),
adapterType: z.string().max(100), receipt: checkpointSchema,
});
export type UsageReceiptEnvelope = z.infer<typeof envelopeSchema>;
export const usageReceiptSpoolPath = () => path.join(resolvePaperclipInstanceRoot(), "accounting-receipts");
export async function spoolUsageReceipt(envelope: UsageReceiptEnvelope, directory = usageReceiptSpoolPath()) {
const parsed = envelopeSchema.parse(envelope);
await ensureDurableDirectory(directory);
const target = path.join(directory, `${parsed.id}.json`);
const temporary = `${target}.${randomUUID()}.tmp`;
const handle = await fs.open(temporary, "wx", 0o600);
try {
try { await handle.writeFile(JSON.stringify(parsed)); await handle.sync(); }
finally { await handle.close(); }
await fs.rename(temporary, target); await syncDirectory(directory);
} catch (error) { await fs.rm(temporary, { force: true }); throw error; }
return target;
}
export async function persistUsageReceipt(db: Db, raw: UsageReceiptEnvelope) {
const envelope = envelopeSchema.parse(raw);
const hash = receiptFingerprint(envelope);
return withAccountingTransaction(db, envelope.companyId, async tx => {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, envelope.runId), eq(heartbeatRuns.companyId, envelope.companyId))).for("update");
if (!run) throw notFound("Accounting run not found");
const [existing] = await tx.select().from(runUsageReceipts).where(and(eq(runUsageReceipts.companyId, envelope.companyId), eq(runUsageReceipts.runId, envelope.runId), eq(runUsageReceipts.sourceId, envelope.sourceId), eq(runUsageReceipts.sequence, envelope.sequence)));
if (existing && existing.receiptHash !== hash) throw conflict("Conflicting usage checkpoint replay");
if (!existing) await tx.insert(runUsageReceipts).values({
id: envelope.id, companyId: envelope.companyId, runId: envelope.runId, sourceId: envelope.sourceId,
sequence: envelope.sequence, receiptHash: hash, receiptJson: envelope, complete: envelope.receipt.complete, receivedAt: new Date(envelope.receivedAt),
});
const previous = run.usageJson ?? {};
// A restarted/replaced controller owns a fresh source. Old spool files
// remain auditable but cannot overwrite its snapshots or acknowledged work.
if (run.costAccountedAt || previous.accountingReceiptSourceId !== envelope.sourceId || Number(previous.accountingReceiptSequence ?? 0) >= envelope.sequence) return;
const receipt = envelope.receipt;
const billingType = receipt.billingType === "api" ? "metered_api" : receipt.billingType === "subscription" ? "subscription_included" : receipt.billingType ?? "unknown";
await tx.update(heartbeatRuns).set({
costAccountingPending: true,
accountingLastError: null,
usageJson: { ...previous, ...receipt.usage,
provider: receipt.provider ?? "unknown", biller: receipt.biller ?? receipt.provider ?? "unknown", model: receipt.model ?? "unknown", billingType,
costUsd: receipt.costUsd ?? null, costUsdExact: receipt.costUsdExact ?? null, cacheAdjustedCostUsd: receipt.cacheAdjustedCostUsd ?? null,
usageByModel: receipt.usageByModel ?? null, usageSource: receipt.usageBasis ?? "per_run", providerRequestId: receipt.providerRequestId ?? null,
accountingReceiptReady: receipt.complete && receipt.usageBasis !== "session_cumulative", accountingReceiptId: envelope.id,
accountingReceiptSequence: envelope.sequence, accountingReceiptReceivedAt: envelope.receivedAt,
costStatus: receipt.costStatus ?? null,
pricingProvenance: receipt.pricingProvenance ?? { source: receipt.costUsd != null || receipt.costUsdExact != null ? "provider_reported" : "unknown", version: "accounting-receipt/v1" },
},
}).where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.costAccountedAt)));
});
}
export async function replayUsageReceipts(db: Db, directory = usageReceiptSpoolPath(), limit = 100) {
let files: string[];
try { files = (await fs.readdir(directory)).filter(name => name.endsWith(".json")).sort(); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { replayed: 0, failed: 0 }; throw error; }
let replayed = 0, failed = 0;
for (const name of files.slice(0, limit)) {
const file = path.join(directory, name);
try {
const info = await fs.lstat(file);
if (!info.isFile() || info.size > 1024 * 1024) throw new Error("Invalid accounting spool entry");
await persistUsageReceipt(db, envelopeSchema.parse(JSON.parse(await fs.readFile(file, "utf8"))));
await fs.rm(file, { force: true }); replayed++;
} catch (error) {
failed++;
// Rotate failures behind fresh entries without discarding the evidence.
await fs.rename(file, path.join(directory, `retry-${Date.now()}-${randomUUID()}.json`)).catch(() => undefined);
logger.error({ err: error, receiptFile: name }, "Accounting spool receipt remains pending");
}
}
return { replayed, failed };
}
type ReceiptIdentity = { companyId: unknown; runId: unknown } | null;
export type UsageReceiptIndex = Map<string, ReceiptIdentity>;
function receiptIdentity(raw: unknown): ReceiptIdentity {
return raw && typeof raw === "object" && "companyId" in raw && "runId" in raw
? { companyId: raw.companyId, runId: raw.runId } : null;
}
/** Build once per recovery batch, before acquiring any accounting lock.
* Spool publications are immutable, uniquely named files. Cache identities,
* never receipt contents: matching evidence is revalidated during settlement. */
export async function indexPendingUsageReceipts(directory = usageReceiptSpoolPath()): Promise<UsageReceiptIndex> {
const index: UsageReceiptIndex = new Map();
let names: string[];
try { names = await fs.readdir(directory); }
// Indexing is only an optimization. The locked drain reports missing or
// unreadable storage through the existing per-run recovery error path.
catch { return index; }
for (const name of names.filter(name => name.endsWith(".json"))) {
const file = path.join(directory, name);
try {
const info = await fs.lstat(file);
if (!info.isFile() || info.size > 1024 * 1024) { index.set(file, null); continue; }
index.set(file, receiptIdentity(JSON.parse(await fs.readFile(file, "utf8"))));
} catch (error) {
if (error instanceof SyntaxError) index.set(file, null);
// Unreadable or concurrently moved entries remain unknown and are
// checked again under the lock, preserving per-run failure reporting.
}
}
return index;
}
export async function recoverPendingRunUsageReceipts(db: Db, input: { companyId: string; runId: string }, directory = usageReceiptSpoolPath(), options: { retainFiles?: boolean; index?: UsageReceiptIndex } = {}) {
// The bounded sweep may not reach this run. Its durable evidence must
// reach the journal before source replacement or ledger acknowledgement.
// A failed save aborts the operation so recovery can retry.
const recovered = new Set<string>();
// A concurrent failed replay can rename a file after releasing its company
// lock. Rescan disappearing paths rather than certify an incomplete scan.
for (let scan = 0; scan < 3; scan++) {
let names: string[];
try { names = await fs.readdir(directory); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return [...recovered]; throw error; }
let moved = false;
for (const name of names.filter(name => name.endsWith(".json"))) {
const file = path.join(directory, name);
if (recovered.has(file)) continue;
const indexed = options.index?.get(file);
if (indexed !== undefined && (indexed?.companyId !== input.companyId || indexed?.runId !== input.runId)) continue;
let raw: unknown;
try {
const info = await fs.lstat(file);
if (!info.isFile() || info.size > 1024 * 1024) continue;
raw = JSON.parse(await fs.readFile(file, "utf8"));
} catch (error) {
// The background replayer can move/remove a file while we scan. Invalid
// JSON remains on disk for its normal recovery/error reporting path.
if ((error as NodeJS.ErrnoException).code === "ENOENT") { moved = true; continue; }
if (error instanceof SyntaxError) continue;
throw error;
}
const identity = receiptIdentity(raw);
options.index?.set(file, identity);
if (identity?.companyId !== input.companyId || identity?.runId !== input.runId) continue;
await persistUsageReceipt(db, envelopeSchema.parse(raw));
// Settlement uses the caller's transaction: deleting before its commit
// would lose the only durable evidence if the later ledger write fails.
recovered.add(file);
if (!options.retainFiles) await fs.rm(file, { force: true });
}
if (!moved) return [...recovered];
}
throw new Error("Accounting spool changed during recovery; retry required");
}
export async function createRunUsageRecorder(db: Db, input: { companyId: string; runId: string; adapterType: string }, directory = usageReceiptSpoolPath()) {
const sourceId = randomUUID();
// Verify durable storage before starting paid work, including a directory
// sync. The database binding fences checkpoints from replaced controllers.
await ensureDurableDirectory(directory);
const probe = await fs.open(path.join(directory, `${sourceId}.probe`), "wx", 0o600);
try { await probe.sync(); } finally { await probe.close(); await fs.rm(path.join(directory, `${sourceId}.probe`)); }
await syncDirectory(directory);
await recoverPendingRunUsageReceipts(db, input, directory);
const bound = await db.update(heartbeatRuns).set({ usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0, accountingReceiptReady: false })}::jsonb` })
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId), isNull(heartbeatRuns.costAccountedAt))).returning({ id: heartbeatRuns.id, runtimeMode: heartbeatRuns.runtimeMode, usageJson: heartbeatRuns.usageJson });
if (bound.length !== 1) throw conflict("Run cannot accept a new usage recorder");
let sequence = 0;
let currentAttempt = "default";
const attempts = new Map<string, AdapterUsageCheckpoint>();
// Native run deltas retain their baseline across same-run recovery. Restore
// the previous snapshot as the same attempt, never add it a second time.
// An empty final result must not erase the last durable observation.
const priorReceiptId = bound[0].usageJson?.accountingReceiptId;
if (bound[0].runtimeMode === "native" && typeof priorReceiptId === "string") {
const [previous] = await db.select().from(runUsageReceipts).where(and(
eq(runUsageReceipts.id, priorReceiptId), eq(runUsageReceipts.runId, input.runId), eq(runUsageReceipts.companyId, input.companyId),
));
if (previous) attempts.set("default", envelopeSchema.parse(previous.receiptJson).receipt as AdapterUsageCheckpoint);
}
let chain = Promise.resolve();
let captureFailed = false;
async function capture(raw: AdapterUsageCheckpoint) {
const parsed = checkpointSchema.parse(raw) as AdapterUsageCheckpoint;
const attempt = parsed.attemptId ?? currentAttempt;
if (!attempts.has(attempt)) currentAttempt = attempt;
const previous = attempts.get(attempt);
const priced = priceCodexReceipt({ ...parsed, usage: parsed.usage ?? previous?.usage,
complete: parsed.complete && !(previous?.usage && !parsed.usage) });
priced.pricingProvenance ??= {
source: priced.costUsd != null || priced.costUsdExact != null || priced.cacheAdjustedCostUsd != null ? "provider_reported" : "unknown",
version: "accounting-receipt/v1",
};
attempts.set(attempt, priced);
const parts = [...attempts.values()];
const latest = attempts.get(currentAttempt)!;
const receipt: AdapterUsageCheckpoint = { ...latest, complete: latest.complete && !captureFailed, usage: {
inputTokens: parts.reduce((sum, item) => sum + (item.usage?.inputTokens ?? 0), 0),
cachedInputTokens: parts.reduce((sum, item) => sum + (item.usage?.cachedInputTokens ?? 0), 0),
...(parts.some(item => item.usage?.cacheWriteTokens !== undefined) ? { cacheWriteTokens: parts.reduce((sum, item) => sum + (item.usage?.cacheWriteTokens ?? 0), 0) } : {}),
outputTokens: parts.reduce((sum, item) => sum + (item.usage?.outputTokens ?? 0), 0),
} };
if (parts.length > 1) {
let total = 0n, priced = true;
for (const part of parts) {
const subscription = ["subscription", "subscription_included"].includes(part.billingType ?? "");
const cost = subscription ? 0 : part.cacheAdjustedCostUsd ?? part.costUsdExact ?? part.costUsd;
if (cost == null || (!subscription && part.costStatus === "unpriced")) priced = false;
if (cost != null) total += usdToUnits(cost);
}
if (new Set(parts.map(part => part.billingType ?? "unknown")).size > 1) receipt.billingType = "unknown";
const decimal = `${total / 1_000_000_000n}.${String(total % 1_000_000_000n).padStart(9, "0")}`;
receipt.costUsdExact = decimal;
receipt.costUsd = Number(decimal);
receipt.cacheAdjustedCostUsd = null;
// A rejected resume can report a complete, explicit zero before the
// successful retry. It contributes neither spend nor tokens and must
// not discard the successful attempt's known model attribution.
const attributed = parts.filter(part => {
const cost = part.cacheAdjustedCostUsd ?? part.costUsdExact ?? part.costUsd;
return !part.complete || part.costStatus === "unpriced" || cost == null || usdToUnits(cost) !== 0n
|| Object.values(part.usage ?? {}).some(value => (value ?? 0) !== 0);
});
receipt.usageByModel = attributed.length === 1 ? attributed[0].usageByModel : undefined;
receipt.costStatus = priced && parts.some(part => part.costStatus === "estimated") ? "estimated" : priced ? "reported" : "unpriced";
receipt.pricingProvenance = { source: receipt.costStatus === "estimated" ? "rate_card" : "unknown", version: "per-attempt/v1", evidence: "Sum of attempted-run receipts; component prices are retained in the immutable receipt journal." };
// Every attempted provider must supply a final receipt before the total
// can be considered complete, even if a later attempt succeeded.
receipt.complete = !captureFailed && parts.every(part => part.complete);
}
const envelope: UsageReceiptEnvelope = { schema: "paperclip/accounting-receipt/v1", id: randomUUID(), ...input, sourceId, sequence: ++sequence, receivedAt: new Date().toISOString(), receipt };
const file = await spoolUsageReceipt(envelope, directory);
try { await persistUsageReceipt(db, envelope); await fs.rm(file, { force: true }); }
catch (error) { logger.warn({ err: error, runId: input.runId, receiptId: envelope.id }, "Usage receipt is durable on disk; database replay pending"); }
return receipt;
}
return {
capture: (receipt: AdapterUsageCheckpoint) => {
const result = chain.then(() => capture(receipt)); chain = result.then(() => undefined, () => { captureFailed = true; }); return result;
},
complete: async (result: AdapterExecutionResult) => {
await chain;
const final = checkpointSchema.parse({ ...result, complete: result.usageComplete !== false }) as AdapterUsageCheckpoint;
const previous = attempts.get(currentAttempt);
// A bounded stdout tail can lose a terminal record already saved from the
// full stream. Keep that stronger evidence, but never hide additional
// usage/charges or certify a snapshot inherited from a replaced recorder.
if (sequence > 0 && previous?.complete && result.usageComplete === false) {
const moreTokens = (["inputTokens", "cachedInputTokens", "cacheWriteTokens", "outputTokens"] as const)
.some(key => (final.usage?.[key] ?? 0) > (previous.usage?.[key] ?? 0));
const finalPrice = final.cacheAdjustedCostUsd ?? final.costUsdExact ?? final.costUsd;
const previousPrice = previous.cacheAdjustedCostUsd ?? previous.costUsdExact ?? previous.costUsd;
const moreCost = finalPrice != null && (previousPrice == null || usdToUnits(finalPrice) > usdToUnits(previousPrice));
if (!moreTokens && !moreCost) return capture(previous);
}
return capture(final);
},
};
}
+6 -1
View File
@@ -5,6 +5,12 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: [
{
find: /^@paperclipai\/paperclip-runner\/live$/,
replacement: fileURLToPath(
new URL("../packages/paperclip-runner/src/live/index.ts", import.meta.url),
),
},
{
find: /^@paperclipai\/paperclip-runner$/,
replacement: fileURLToPath(
@@ -37,7 +43,6 @@ export default defineConfig({
isolate: true,
maxConcurrency: 1,
maxWorkers: 1,
minWorkers: 1,
pool: "forks",
// Server suites share process state and one embedded Postgres instance,
// so tests inside a file must run one at a time. Do not set