Fix issue run lookup and heartbeat run summaries
This commit is contained in:
70
server/src/__tests__/activity-routes.test.ts
Normal file
70
server/src/__tests__/activity-routes.test.ts
Normal file
@@ -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" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
server/src/__tests__/heartbeat-run-summary.test.ts
Normal file
33
server/src/__tests__/heartbeat-run-summary.test.ts
Normal file
@@ -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<string, unknown>)).toBeNull();
|
||||||
|
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,13 @@ export function activityRoutes(db: Db) {
|
|||||||
const svc = activityService(db);
|
const svc = activityService(db);
|
||||||
const issueSvc = issueService(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) => {
|
router.get("/companies/:companyId/activity", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
@@ -47,42 +54,27 @@ export function activityRoutes(db: Db) {
|
|||||||
res.status(201).json(event);
|
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) => {
|
router.get("/issues/:id/activity", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const rawId = req.params.id as string;
|
||||||
const issue = await issueSvc.getById(id);
|
const issue = await resolveIssueByRef(rawId);
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
res.status(404).json({ error: "Issue not found" });
|
res.status(404).json({ error: "Issue not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
assertCompanyAccess(req, issue.companyId);
|
||||||
const result = await svc.forIssue(id);
|
const result = await svc.forIssue(issue.id);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/issues/:id/runs", async (req, res) => {
|
router.get("/issues/:id/runs", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const rawId = req.params.id as string;
|
||||||
const issue = await issueSvc.getById(id);
|
const issue = await resolveIssueByRef(rawId);
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
res.status(404).json({ error: "Issue not found" });
|
res.status(404).json({ error: "Issue not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
assertCompanyAccess(req, issue.companyId);
|
||||||
const result = await svc.runsForIssue(issue.companyId, id);
|
const result = await svc.runsForIssue(issue.companyId, issue.id);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
35
server/src/services/heartbeat-run-summary.ts
Normal file
35
server/src/services/heartbeat-run-summary.ts
Normal file
@@ -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<string, unknown>, key: string) {
|
||||||
|
return key in record ? record[key] ?? null : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeHeartbeatRunResultJson(
|
||||||
|
resultJson: Record<string, unknown> | null | undefined,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: Record<string, unknown> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
|||||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||||
|
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceReadyComment,
|
buildWorkspaceReadyComment,
|
||||||
ensureRuntimeServicesForRun,
|
ensureRuntimeServicesForRun,
|
||||||
@@ -46,38 +47,6 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
|||||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||||
|
|
||||||
const summarizedHeartbeatRunResultJson = sql<Record<string, unknown> | 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 = {
|
const heartbeatRunListColumns = {
|
||||||
id: heartbeatRuns.id,
|
id: heartbeatRuns.id,
|
||||||
companyId: heartbeatRuns.companyId,
|
companyId: heartbeatRuns.companyId,
|
||||||
@@ -92,7 +61,7 @@ const heartbeatRunListColumns = {
|
|||||||
exitCode: heartbeatRuns.exitCode,
|
exitCode: heartbeatRuns.exitCode,
|
||||||
signal: heartbeatRuns.signal,
|
signal: heartbeatRuns.signal,
|
||||||
usageJson: heartbeatRuns.usageJson,
|
usageJson: heartbeatRuns.usageJson,
|
||||||
resultJson: summarizedHeartbeatRunResultJson.as("resultJson"),
|
resultJson: heartbeatRuns.resultJson,
|
||||||
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
||||||
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
||||||
logStore: heartbeatRuns.logStore,
|
logStore: heartbeatRuns.logStore,
|
||||||
@@ -2336,10 +2305,11 @@ export function heartbeatService(db: Db) {
|
|||||||
)
|
)
|
||||||
.orderBy(desc(heartbeatRuns.createdAt));
|
.orderBy(desc(heartbeatRuns.createdAt));
|
||||||
|
|
||||||
if (limit) {
|
const rows = limit ? await query.limit(limit) : await query;
|
||||||
return query.limit(limit);
|
return rows.map((row) => ({
|
||||||
}
|
...row,
|
||||||
return query;
|
resultJson: summarizeHeartbeatRunResultJson(row.resultJson),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
getRun,
|
getRun,
|
||||||
|
|||||||
Reference in New Issue
Block a user