From d19ff3f4dd2fc7d77d2e9c21cec32d2a21e0f5ff Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 17:23:33 -0500 Subject: [PATCH] Fix issue run lookup and heartbeat run summaries --- server/src/__tests__/activity-routes.test.ts | 70 +++++++++++++++++++ .../__tests__/heartbeat-run-summary.test.ts | 33 +++++++++ server/src/routes/activity.ts | 34 ++++----- server/src/services/heartbeat-run-summary.ts | 35 ++++++++++ server/src/services/heartbeat.ts | 44 ++---------- 5 files changed, 158 insertions(+), 58 deletions(-) create mode 100644 server/src/__tests__/activity-routes.test.ts create mode 100644 server/src/__tests__/heartbeat-run-summary.test.ts create mode 100644 server/src/services/heartbeat-run-summary.ts diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts new file mode 100644 index 00000000..5881af3f --- /dev/null +++ b/server/src/__tests__/activity-routes.test.ts @@ -0,0 +1,70 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { activityRoutes } from "../routes/activity.js"; + +const mockActivityService = vi.hoisted(() => ({ + list: vi.fn(), + forIssue: vi.fn(), + runsForIssue: vi.fn(), + issuesForRun: vi.fn(), + create: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + getByIdentifier: vi.fn(), +})); + +vi.mock("../services/activity.js", () => ({ + activityService: () => mockActivityService, +})); + +vi.mock("../services/index.js", () => ({ + issueService: () => mockIssueService, +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", activityRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("activity routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves issue identifiers before loading runs", async () => { + mockIssueService.getByIdentifier.mockResolvedValue({ + id: "issue-uuid-1", + companyId: "company-1", + }); + mockActivityService.runsForIssue.mockResolvedValue([ + { + runId: "run-1", + }, + ]); + + const res = await request(createApp()).get("/api/issues/PAP-475/runs"); + + expect(res.status).toBe(200); + expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475"); + expect(mockIssueService.getById).not.toHaveBeenCalled(); + expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); + expect(res.body).toEqual([{ runId: "run-1" }]); + }); +}); diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts new file mode 100644 index 00000000..ec6bc2d9 --- /dev/null +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js"; + +describe("summarizeHeartbeatRunResultJson", () => { + it("truncates text fields and preserves cost aliases", () => { + const summary = summarizeHeartbeatRunResultJson({ + summary: "a".repeat(600), + result: "ok", + message: "done", + error: "failed", + total_cost_usd: 1.23, + cost_usd: 0.45, + costUsd: 0.67, + nested: { ignored: true }, + }); + + expect(summary).toEqual({ + summary: "a".repeat(500), + result: "ok", + message: "done", + error: "failed", + total_cost_usd: 1.23, + cost_usd: 0.45, + costUsd: 0.67, + }); + }); + + it("returns null for non-object and irrelevant payloads", () => { + expect(summarizeHeartbeatRunResultJson(null)).toBeNull(); + expect(summarizeHeartbeatRunResultJson(["nope"] as unknown as Record)).toBeNull(); + expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull(); + }); +}); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 8dfd9768..0884b455 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -22,6 +22,13 @@ export function activityRoutes(db: Db) { const svc = activityService(db); const issueSvc = issueService(db); + async function resolveIssueByRef(rawId: string) { + if (/^[A-Z]+-\d+$/i.test(rawId)) { + return issueSvc.getByIdentifier(rawId); + } + return issueSvc.getById(rawId); + } + router.get("/companies/:companyId/activity", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -47,42 +54,27 @@ export function activityRoutes(db: Db) { res.status(201).json(event); }); - // Resolve issue identifiers (e.g. "PAP-39") to UUIDs - router.param("id", async (req, res, next, rawId) => { - try { - if (/^[A-Z]+-\d+$/i.test(rawId)) { - const issue = await issueSvc.getByIdentifier(rawId); - if (issue) { - req.params.id = issue.id; - } - } - next(); - } catch (err) { - next(err); - } - }); - router.get("/issues/:id/activity", async (req, res) => { - const id = req.params.id as string; - const issue = await issueSvc.getById(id); + const rawId = req.params.id as string; + const issue = await resolveIssueByRef(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); - const result = await svc.forIssue(id); + const result = await svc.forIssue(issue.id); res.json(result); }); router.get("/issues/:id/runs", async (req, res) => { - const id = req.params.id as string; - const issue = await issueSvc.getById(id); + const rawId = req.params.id as string; + const issue = await resolveIssueByRef(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); - const result = await svc.runsForIssue(issue.companyId, id); + const result = await svc.runsForIssue(issue.companyId, issue.id); res.json(result); }); diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts new file mode 100644 index 00000000..4ef07047 --- /dev/null +++ b/server/src/services/heartbeat-run-summary.ts @@ -0,0 +1,35 @@ +function truncateSummaryText(value: unknown, maxLength = 500) { + if (typeof value !== "string") return null; + return value.length > maxLength ? value.slice(0, maxLength) : value; +} + +function readNumericField(record: Record, key: string) { + return key in record ? record[key] ?? null : undefined; +} + +export function summarizeHeartbeatRunResultJson( + resultJson: Record | null | undefined, +): Record | null { + if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) { + return null; + } + + const summary: Record = {}; + const textFields = ["summary", "result", "message", "error"] as const; + for (const key of textFields) { + const value = truncateSummaryText(resultJson[key]); + if (value !== null) { + summary[key] = value; + } + } + + const numericFieldAliases = ["total_cost_usd", "cost_usd", "costUsd"] as const; + for (const key of numericFieldAliases) { + const value = readNumericField(resultJson, key); + if (value !== undefined && value !== null) { + summary[key] = value; + } + } + + return Object.keys(summary).length > 0 ? summary : null; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f4c2715b..3ced47fc 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -24,6 +24,7 @@ import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; +import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js"; import { buildWorkspaceReadyComment, ensureRuntimeServicesForRun, @@ -46,38 +47,6 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; -const summarizedHeartbeatRunResultJson = sql | null>` - CASE - WHEN ${heartbeatRuns.resultJson} IS NULL THEN NULL - ELSE NULLIF( - jsonb_strip_nulls( - jsonb_build_object( - 'summary', CASE - WHEN ${heartbeatRuns.resultJson} ->> 'summary' IS NULL THEN NULL - ELSE left(${heartbeatRuns.resultJson} ->> 'summary', 500) - END, - 'result', CASE - WHEN ${heartbeatRuns.resultJson} ->> 'result' IS NULL THEN NULL - ELSE left(${heartbeatRuns.resultJson} ->> 'result', 500) - END, - 'message', CASE - WHEN ${heartbeatRuns.resultJson} ->> 'message' IS NULL THEN NULL - ELSE left(${heartbeatRuns.resultJson} ->> 'message', 500) - END, - 'error', CASE - WHEN ${heartbeatRuns.resultJson} ->> 'error' IS NULL THEN NULL - ELSE left(${heartbeatRuns.resultJson} ->> 'error', 500) - END, - 'total_cost_usd', ${heartbeatRuns.resultJson} -> 'total_cost_usd', - 'cost_usd', ${heartbeatRuns.resultJson} -> 'cost_usd', - 'costUsd', ${heartbeatRuns.resultJson} -> 'costUsd' - ) - ), - '{}'::jsonb - ) - END -`; - const heartbeatRunListColumns = { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, @@ -92,7 +61,7 @@ const heartbeatRunListColumns = { exitCode: heartbeatRuns.exitCode, signal: heartbeatRuns.signal, usageJson: heartbeatRuns.usageJson, - resultJson: summarizedHeartbeatRunResultJson.as("resultJson"), + resultJson: heartbeatRuns.resultJson, sessionIdBefore: heartbeatRuns.sessionIdBefore, sessionIdAfter: heartbeatRuns.sessionIdAfter, logStore: heartbeatRuns.logStore, @@ -2336,10 +2305,11 @@ export function heartbeatService(db: Db) { ) .orderBy(desc(heartbeatRuns.createdAt)); - if (limit) { - return query.limit(limit); - } - return query; + const rows = limit ? await query.limit(limit) : await query; + return rows.map((row) => ({ + ...row, + resultJson: summarizeHeartbeatRunResultJson(row.resultJson), + })); }, getRun,