mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Durable accounting needs repeatable regression gates and inspectable operational guidance. > - Broad suite success alone does not prove that crash recovery or critical financial invariants are tested. > - This pull request adds per-module coverage floors, mutation sentinels and an accounting operations guide. > - CI detects weakened accounting tests and operators can inspect failures without guessing. ## Linked Issues or Issue Description Refs #14548, #5598. Completes the verification for #14997 and #14999. Stack order: #14989 → #14991 → #14994 → #14997 → #14999 → #15001. Part 6 of 6. Review against `fix/costs-dashboard-accounting`. Merge authorized; landing in dependency order. ## What Changed - Add and typecheck focused PostgreSQL tests for crash recovery, concurrency, receipt replay, reporting and operator recovery, plus exact-money and provider-import tests. - Require saved passing results for all four SIGKILL cases and both historical migration cases; skipped or missing cases fail. The historical runtime fixture restores bigint and verifies an exact lifetime total beyond JavaScript’s safe integer range. - Require each of thirteen accounting service modules to meet its own coverage thresholds. - Kill deliberate mutations of receipt deduplication, company isolation, projection conservation and budget boundaries at their specifically marked assertions; reject setup errors, timeouts and unrelated failures. - Retain mutation and coverage evidence on failures and upload it from the trusted CI workflow. - Preserve frozen-only installs for canary onboarding, optional E2E, Storybook visual and live provider evals. Execute regressions that require stale locks to fail closed without registry resolution; the existing repository bot owns lockfile refreshes. - Add a scale benchmark and the final implementation/operations guide, with spec links introduced alongside the guide. - Exercise empty-turn recovery through the real usage translator with legacy/current receipts, complete/incomplete flags, malformed metadata and counter checks. Keep Cloud-auth fixture teardown hardening in this test gate. - Cover ACPX completeness through the real notification translator and driver, plus additive reservation/dispatch and exact daily-spend assertions. - Drain Cloud-auth browser route handlers before teardown. - Require frozen-only installation in the accounting CI lane and gate coverage execution on successful installation, so the provider cannot come from inline registry resolution. Other CI lanes keep their existing install policy. - Test fractional status-card and skill-test response costs, invalid monetary values and integer token counters. Include the additive accounting OpenAPI assertions. - Cover missing/malformed/mismatched billing secret designations, privileged caller roles and inactive or cross-company memberships. Document dedicated billing-secret setup. Include fractional and large status-card cost regressions. ## Verification - `pnpm test:accounting` checks 98% lines, 90% branches, 96% statements and 96% functions per service module, plus required migration/recovery evidence and mutation checks. - `node --test scripts/test-accounting-mutations.test.mjs scripts/verify-accounting-test-results.test.mjs` passed all 22 gate-harness scenarios. - Inspect `coverage/accounting/` for coverage and test/mutation evidence. The benchmark is an explicit operator command, not a timing threshold in CI. - All 30 release/workflow shard tests and actionlint pass. The new regression executes the actual accounting-lane install: a stale lock exits without any registry resolution; a current lock proceeds. It fails against the old fallback. Integrated-stack validation on master `c365a16e346d`: repository-wide typechecking and production build, token gates, 313 focused accounting tests, two historical migration tests, four required SIGKILL cases and four mutation sentinels pass. The thirteen accounting modules meet their individual coverage floors (aggregate 99.7% lines, 94.82% branches). CI fixture regressions pass 48 connection-card cases, both recovery release orders and the personal-connection browser workflow. Final consumer regressions pass all 28 Browser Use lifecycle, 42 email pipeline and 140 rendered issue-detail cases; local email verification used this checkout’s bundled skills to avoid an unrelated ancestor skills directory. Merged-source regressions pass AI connections (107), route authorization (23), worktree cloning (63) and ACPX execution (221). Additional checks during stack preparation passed native execution (565), heartbeat admission (60), OpenCode/checkpoint/failure-path (102), Codex runner drivers (316) and Costs UI (21). Runtime/connection (644), adapter/billing (86), all Codex adapter cases (509 passed, one skipped), dashboard (106), worktree-cloning (63), CLI cost (14) and gate-harness (48) tests pass. Release workflow tests (18) and actionlint pass; frozen-install shell regressions confirm current locks proceed and stale locks fail without retries. The repository lockfile bot must land the coverage dependency refresh before frozen-install workflows run. All three accounting browser workflows pass on the current merged tree; the Storybook build and populated provider/biller smoke check passed earlier in stack preparation. Landing validation against the six intervening master changes passes all 282 targeted UI tests, 18 workflow tests and repository-wide typechecking; Storybook host validation adds 19 passing cases and 18 live HTTP checks, with UI typechecking and token gates passing. After master consolidated Cursor support, the one cancellation overlap preserves both current-budget fencing and the new manual cancellation identities. All 642 affected heartbeat/cancellation/native/Cursor tests, repository-wide typechecking and production build pass after reinstalling the updated ACPX patch. The rebuilt final tree matches that combined source. Each PR’s standalone, latest-head CI status is recorded below. Latest-head CI has not yet passed. Latest-head Greptile sign-off is pending. A fresh Apex review will be requested for this authorized merge. ## Risks The focused gate adds CI time and requires PostgreSQL support. Trusted workflow changes take effect when this PR reaches master. Coverage does not claim to cover every heartbeat, adapter or browser path. Frozen-install jobs stop until the repository bot refreshes pnpm-lock.yaml for the new coverage dependency; no fallback runs unreviewed dependency code in those jobs. ## Model Used OpenAI Codex, based on GPT-6, with repository inspection, code editing and test execution. The exact serving model ID and context window were not exposed to this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
170 lines
14 KiB
TypeScript
170 lines
14 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
||
import { reserveRunBudget } from "../src/services/budget-reservations.js";
|
||
import { createRunUsageRecorder, indexPendingUsageReceipts, usageReceiptSpoolPath } from "../src/services/usage-receipts.js";
|
||
import { budgetService } from "../src/services/budgets.js";
|
||
import { sql } from "drizzle-orm";
|
||
/** Reproducible synthetic PostgreSQL benchmark. Never reads DATABASE_URL or
|
||
* provider credentials; startEmbeddedPostgresTestDatabase owns all data. */
|
||
import { performance, monitorEventLoopDelay } from "node:perf_hooks";
|
||
import { mkdir, writeFile, mkdtemp, rm } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { agents, companies, projects, heartbeatRuns, budgetPolicies, createDb, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
||
import { costService } from "../src/services/costs.js";
|
||
import { withAccountingTransaction } from "../src/services/accounting-transaction.js";
|
||
import { accountingIntegrityService } from "../src/services/accounting-integrity.js";
|
||
import { reconcileRunCosts } from "../src/services/run-cost-accounting.js";
|
||
const count = Number(process.env.PAPERCLIP_ACCOUNTING_BENCH_ROWS ?? 1_000_000);
|
||
if (!Number.isInteger(count) || count < 1000 || count > 10_000_000) throw new Error("Benchmark row count must be 1,000–10,000,000");
|
||
// Recovery reads the instance receipt spool as well as the database. Always
|
||
// own both; a caller's real PAPERCLIP_HOME must never be scanned or replayed.
|
||
const previousHome = process.env.PAPERCLIP_HOME;
|
||
const benchmarkHome = await mkdtemp(path.join(os.tmpdir(), "accounting-benchmark-"));
|
||
process.env.PAPERCLIP_HOME = benchmarkHome;
|
||
const database = await startEmbeddedPostgresTestDatabase("accounting-scale-").catch(async error => {
|
||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
||
await rm(benchmarkHome, { recursive: true, force: true });
|
||
throw error;
|
||
});
|
||
const db = createDb(database.connectionString);
|
||
const started = performance.now();
|
||
const eventLoop = monitorEventLoopDelay({ resolution: 10 }); eventLoop.enable();
|
||
function distribution(values: number[]) {
|
||
const sorted = [...values].sort((a,b) => a-b);
|
||
const pick = (p: number) => Number(sorted[Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1)].toFixed(2));
|
||
return { samples: sorted.length, p50Ms: pick(0.5), p95Ms: pick(0.95), p99Ms: pick(0.99), maxMs: pick(1) };
|
||
}
|
||
async function time(work: () => Promise<unknown>) { const start = performance.now(); await work(); return performance.now() - start; }
|
||
try {
|
||
const [company] = await db.insert(companies).values({ name: "Scale fixture", issuePrefix: "SCALE" }).returning();
|
||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", adapterType: "process", status: "idle" }).returning();
|
||
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Hot project" }).returning();
|
||
const seedMs = await time(async () => {
|
||
await db.$client`insert into cost_events (company_id,agent_id,project_id,provider,biller,model,cost_cents,input_tokens,cached_input_tokens,output_tokens,occurred_at)
|
||
select ${company.id},${agent.id}, case when i % 100 = 0 then ${project.id}::uuid else null end, 'fixture','fixture','fixture', 0.1234567,7,11,3,
|
||
date_trunc('month',now() at time zone 'UTC') at time zone 'UTC' - (i % 12) * interval '1 month'
|
||
from generate_series(1,${count}) i`;
|
||
await db.$client`analyze cost_events`;
|
||
// Seed projections through an explicit reviewed repair, not benchmark-only SQL.
|
||
const integrity = accountingIntegrityService(db); const review = await integrity.inspect(company.id);
|
||
await integrity.repair(company.id, review.fingerprint, "Initialize synthetic benchmark projections", "benchmark");
|
||
});
|
||
const reports: number[] = [], projectReports: number[] = [];
|
||
for (let i = 0; i < 20; i++) {
|
||
reports.push(await time(() => costService(db).summary(company.id, { allTime: true })));
|
||
projectReports.push(await time(() => costService(db).byProject(company.id, { allTime: true })));
|
||
}
|
||
const writes: number[] = []; const concurrency = 8; const writeStarted = performance.now();
|
||
for (let batch = 0; batch < 8; batch++) await Promise.all(Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
|
||
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `bench:${batch}:${index}`, occurredAt: new Date(),
|
||
})).then(ms => writes.push(ms))));
|
||
const writeMs = performance.now() - writeStarted;
|
||
await budgetService(db).upsertPolicy(company.id, { scopeType: "company", scopeId: company.id, amount: 2_000_000_000 }, "benchmark");
|
||
await budgetService(db).upsertPolicy(company.id, { scopeType: "agent", scopeId: agent.id, amount: 2_000_000_000 }, "benchmark");
|
||
const budgetedWrites: number[] = [];
|
||
const budgetedStart = performance.now();
|
||
for (let batch = 0; batch < 4; batch++) await Promise.all(Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
|
||
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `budgeted:${batch}:${index}`, occurredAt: new Date(),
|
||
})).then(ms => budgetedWrites.push(ms))));
|
||
const budgetedMs = performance.now() - budgetedStart;
|
||
const admissions: number[] = [];
|
||
for (let i = 0; i < 20; i++) {
|
||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running" }).returning();
|
||
admissions.push(await time(() => reserveRunBudget(db, company.id, run.id, null)));
|
||
}
|
||
const dashboard: number[] = [], writesWhilePolling: number[] = [];
|
||
// Eight viewers each issue the ledger queries used by the Costs overview,
|
||
// together with budget and accounting health reads, while eight writes run.
|
||
for (let batch = 0; batch < 4; batch++) await Promise.all([
|
||
...Array.from({ length: 8 }, () => time(() => Promise.all([
|
||
costService(db).summary(company.id), costService(db).byAgent(company.id),
|
||
costService(db).byProject(company.id), costService(db).byAgentModel(company.id),
|
||
budgetService(db).overview(company.id), accountingIntegrityService(db).health(company.id),
|
||
])).then(ms => dashboard.push(ms))),
|
||
...Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
|
||
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `polling:${batch}:${index}`, occurredAt: new Date(),
|
||
})).then(ms => writesWhilePolling.push(ms))),
|
||
]);
|
||
const [checkpointRun] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running" }).returning();
|
||
const recorder = await createRunUsageRecorder(db, { companyId: company.id, runId: checkpointRun.id, adapterType: "process" });
|
||
const checkpoints: number[] = [];
|
||
for (let i = 1; i <= 50; i++) checkpoints.push(await time(() => recorder.capture({ complete: false, usageBasis: "per_run", usage: { inputTokens: i, outputTokens: i }, costUsdExact: "0.000000001" })));
|
||
// Include historical run-table size, independently of the pending index.
|
||
await db.$client`insert into heartbeat_runs (company_id,agent_id,status,cost_accounting_pending,finished_at)
|
||
select ${company.id},${agent.id},'succeeded',false, now() from generate_series(1,100000)`;
|
||
await db.$client`analyze heartbeat_runs`;
|
||
const health: number[] = [];
|
||
for (let i = 0; i < 20; i++) health.push(await time(() => accountingIntegrityService(db).health(company.id)));
|
||
const waits: number[] = [];
|
||
await Promise.all(Array.from({ length: concurrency }, () => {
|
||
const start = performance.now();
|
||
return withAccountingTransaction(db, company.id, async tx => { waits.push(performance.now() - start); await tx.execute(sql`select pg_sleep(0.005)`); });
|
||
}));
|
||
await db.$client`insert into heartbeat_runs (company_id,agent_id,status,cost_accounting_pending,usage_json,finished_at)
|
||
select ${company.id},${agent.id},'failed',true,'{"accountingReceiptReady":true,"costUsdExact":"0.000000001","inputTokens":1}'::jsonb, now() from generate_series(1,250)`;
|
||
const recovery: Array<{ milliseconds: number; scanned: number; accounted: number }> = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
const before = performance.now(); const result = await reconcileRunCosts(db); recovery.push({ milliseconds: performance.now()-before, ...result });
|
||
}
|
||
if (recovery.reduce((sum, r) => sum+r.accounted,0) !== 250) throw new Error("Benchmark recovery did not conserve receipt count");
|
||
const spool = usageReceiptSpoolPath(); await mkdir(spool, { recursive: true });
|
||
const spoolBacklogs: Array<{ files: number; indexMs: number; recorderStartMs: number }> = [];
|
||
const foreignRunId = randomUUID();
|
||
let published = 0;
|
||
for (const files of [1000, 10000]) {
|
||
// Publish synthetic immutable files directly; timed checkpoints above use
|
||
// the real fsync path. These belong to another run and must be retained.
|
||
for (; published < files; published += 50) await Promise.all(Array.from({ length: 50 }, (_, offset) => writeFile(path.join(spool, `${published + offset}.json`), JSON.stringify({
|
||
schema: "paperclip/accounting-receipt/v1", id: randomUUID(), companyId: company.id, runId: foreignRunId,
|
||
sourceId: randomUUID(), sequence: 1, receivedAt: new Date().toISOString(), adapterType: "process", receipt: { complete: false },
|
||
}))));
|
||
const indexMs = await time(() => indexPendingUsageReceipts());
|
||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running" }).returning();
|
||
const recorderStartMs = await time(() => createRunUsageRecorder(db, { companyId: company.id, runId: run.id, adapterType: "process" }));
|
||
spoolBacklogs.push({ files, indexMs, recorderStartMs });
|
||
}
|
||
await rm(spool, { recursive: true, force: true });
|
||
// A large policy list must not make each write fetch every agent's policies.
|
||
const extraAgents = await db.insert(agents).values(Array.from({ length: 100 }, (_, i) => ({ companyId: company.id, name: `Idle ${i}`, role: "engineer", adapterType: "process" }))).returning();
|
||
await db.insert(budgetPolicies).values(extraAgents.map(extra => ({ companyId: company.id, scopeType: "agent", scopeId: extra.id, windowKind: "calendar_month_utc", amount: 2_000_000_000 })));
|
||
const largeOverview: number[] = [];
|
||
for (let i = 0; i < 10; i++) largeOverview.push(await time(async () => {
|
||
const overview = await budgetService(db).overview(company.id);
|
||
if (overview.policies.length !== 102) throw new Error("Benchmark policy fixture is incomplete");
|
||
}));
|
||
const largePolicyWrites: number[] = [], largePolicyAdmissions: number[] = [];
|
||
const largePolicyStart = performance.now();
|
||
for (let batch = 0; batch < 4; batch++) await Promise.all(Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
|
||
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `large-policy:${batch}:${index}`, occurredAt: new Date(),
|
||
})).then(ms => largePolicyWrites.push(ms))));
|
||
const largePolicyMs = performance.now() - largePolicyStart;
|
||
for (let i = 0; i < 20; i++) {
|
||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running" }).returning();
|
||
largePolicyAdmissions.push(await time(() => reserveRunBudget(db, company.id, run.id, null)));
|
||
}
|
||
const integrity = await accountingIntegrityService(db).inspect(company.id);
|
||
if (integrity.findings.length) throw new Error(`Benchmark violated accounting integrity: ${JSON.stringify(integrity.findings)}`);
|
||
const plan = await db.$client`explain (analyze,buffers,format json) select sum(cost_cents) from cost_events where company_id = ${company.id} and project_id = ${project.id}
|
||
and occurred_at >= date_trunc('month',now() at time zone 'UTC') at time zone 'UTC'`;
|
||
const report = { generatedAt: new Date().toISOString(), platform: `${os.platform()} ${os.arch()}`, cpus: os.cpus().length, node: process.version,
|
||
seedRows: count, seedMs, concurrency, allTimeSummary: distribution(reports), allTimeProjects: distribution(projectReports),
|
||
hotCompanyWrites: { activePolicies: 0, ...distribution(writes), writesPerSecond: writes.length / (writeMs / 1000) }, companyLockAcquisition: distribution(waits),
|
||
budgetedCompanyWrites: { activePolicies: 2, ...distribution(budgetedWrites), writesPerSecond: budgetedWrites.length / (budgetedMs / 1000) },
|
||
admission: distribution(admissions), concurrentDashboard: distribution(dashboard), writesWhilePolling: distribution(writesWhilePolling),
|
||
durableCheckpoint: distribution(checkpoints), healthWith100kHistoricalRuns: distribution(health),
|
||
overviewWith102Policies: distribution(largeOverview),
|
||
writesWith102Policies: { activePolicies: 102, relevantPolicies: 2, ...distribution(largePolicyWrites), writesPerSecond: largePolicyWrites.length / (largePolicyMs / 1000) },
|
||
admissionWith102Policies: distribution(largePolicyAdmissions), spoolBacklogs,
|
||
eventLoopDelay: { p95Ms: eventLoop.percentile(95) / 1e6, maxMs: eventLoop.max / 1e6 },
|
||
recovery, projectBudgetQueryPlan: plan, integrityFindings: integrity.findings.length, totalMs: performance.now()-started };
|
||
const output = path.resolve("coverage/accounting/scale.json"); await mkdir(path.dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2));
|
||
process.stdout.write(JSON.stringify({ ...report, projectBudgetQueryPlan: "see coverage/accounting/scale.json" },null,2)+"\n");
|
||
} finally {
|
||
eventLoop.disable();
|
||
try { await db.$client.end(); await database.cleanup(); }
|
||
finally {
|
||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
||
await rm(benchmarkHome, { recursive: true, force: true });
|
||
}
|
||
}
|