From 07c2ea34214a6d81be4b2ac309d5c0ca83abf873 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 10:23:21 -0500 Subject: [PATCH] Trim issue run polling payloads Co-Authored-By: Paperclip --- server/src/__tests__/activity-service.test.ts | 116 ++++++++++++++++++ server/src/services/activity.ts | 72 ++++++++++- 2 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/activity-service.test.ts diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts new file mode 100644 index 00000000000..2b3bfc37d8c --- /dev/null +++ b/server/src/__tests__/activity-service.test.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { activityService } from "../services/activity.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres activity service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("activity service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-activity-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("returns compact usage and result summaries for issue runs", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + contextSnapshot: { issueId }, + usageJson: { + inputTokens: 11, + output_tokens: 7, + cache_read_input_tokens: 3, + billingType: "metered", + costUsd: 0.42, + enormousBlob: "x".repeat(256_000), + }, + resultJson: { + billing_type: "metered", + total_cost_usd: 0.42, + summary: "done", + nestedHuge: { payload: "y".repeat(256_000) }, + }, + }); + + const runs = await activityService(db).runsForIssue(companyId, issueId); + + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + runId, + agentId, + invocationSource: "assignment", + }); + expect(runs[0]?.usageJson).toEqual({ + inputTokens: 11, + input_tokens: 11, + outputTokens: 7, + output_tokens: 7, + cachedInputTokens: 3, + cached_input_tokens: 3, + cache_read_input_tokens: 3, + billingType: "metered", + billing_type: "metered", + costUsd: 0.42, + cost_usd: 0.42, + total_cost_usd: 0.42, + }); + expect(runs[0]?.resultJson).toEqual({ + billingType: "metered", + billing_type: "metered", + costUsd: 0.42, + cost_usd: 0.42, + total_cost_usd: 0.42, + }); + }); +}); diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 62e995307d3..e467ff316d6 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -11,6 +11,74 @@ export interface ActivityFilters { export function activityService(db: Db) { const issueIdAsText = sql`${issues.id}::text`; + const summarizedUsageJson = sql | null>` + case + when ${heartbeatRuns.usageJson} is null then null + else jsonb_strip_nulls(jsonb_build_object( + 'inputTokens', coalesce(${heartbeatRuns.usageJson} -> 'inputTokens', ${heartbeatRuns.usageJson} -> 'input_tokens'), + 'input_tokens', coalesce(${heartbeatRuns.usageJson} -> 'input_tokens', ${heartbeatRuns.usageJson} -> 'inputTokens'), + 'outputTokens', coalesce(${heartbeatRuns.usageJson} -> 'outputTokens', ${heartbeatRuns.usageJson} -> 'output_tokens'), + 'output_tokens', coalesce(${heartbeatRuns.usageJson} -> 'output_tokens', ${heartbeatRuns.usageJson} -> 'outputTokens'), + 'cachedInputTokens', coalesce( + ${heartbeatRuns.usageJson} -> 'cachedInputTokens', + ${heartbeatRuns.usageJson} -> 'cached_input_tokens', + ${heartbeatRuns.usageJson} -> 'cache_read_input_tokens' + ), + 'cached_input_tokens', coalesce( + ${heartbeatRuns.usageJson} -> 'cached_input_tokens', + ${heartbeatRuns.usageJson} -> 'cachedInputTokens', + ${heartbeatRuns.usageJson} -> 'cache_read_input_tokens' + ), + 'cache_read_input_tokens', coalesce( + ${heartbeatRuns.usageJson} -> 'cache_read_input_tokens', + ${heartbeatRuns.usageJson} -> 'cached_input_tokens', + ${heartbeatRuns.usageJson} -> 'cachedInputTokens' + ), + 'billingType', coalesce(${heartbeatRuns.usageJson} -> 'billingType', ${heartbeatRuns.usageJson} -> 'billing_type'), + 'billing_type', coalesce(${heartbeatRuns.usageJson} -> 'billing_type', ${heartbeatRuns.usageJson} -> 'billingType'), + 'costUsd', coalesce( + ${heartbeatRuns.usageJson} -> 'costUsd', + ${heartbeatRuns.usageJson} -> 'cost_usd', + ${heartbeatRuns.usageJson} -> 'total_cost_usd' + ), + 'cost_usd', coalesce( + ${heartbeatRuns.usageJson} -> 'cost_usd', + ${heartbeatRuns.usageJson} -> 'costUsd', + ${heartbeatRuns.usageJson} -> 'total_cost_usd' + ), + 'total_cost_usd', coalesce( + ${heartbeatRuns.usageJson} -> 'total_cost_usd', + ${heartbeatRuns.usageJson} -> 'cost_usd', + ${heartbeatRuns.usageJson} -> 'costUsd' + ) + )) + end + `.as("usageJson"); + const summarizedResultJson = sql | null>` + case + when ${heartbeatRuns.resultJson} is null then null + else jsonb_strip_nulls(jsonb_build_object( + 'billingType', coalesce(${heartbeatRuns.resultJson} -> 'billingType', ${heartbeatRuns.resultJson} -> 'billing_type'), + 'billing_type', coalesce(${heartbeatRuns.resultJson} -> 'billing_type', ${heartbeatRuns.resultJson} -> 'billingType'), + 'costUsd', coalesce( + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'total_cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'total_cost_usd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd' + ) + )) + end + `.as("resultJson"); + return { list: (filters: ActivityFilters) => { const conditions = [eq(activityLog.companyId, filters.companyId)]; @@ -71,8 +139,8 @@ export function activityService(db: Db) { finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, invocationSource: heartbeatRuns.invocationSource, - usageJson: heartbeatRuns.usageJson, - resultJson: heartbeatRuns.resultJson, + usageJson: summarizedUsageJson, + resultJson: summarizedResultJson, logBytes: heartbeatRuns.logBytes, }) .from(heartbeatRuns)