diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 74400ba9625..2bd6e82bc78 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -28,6 +28,23 @@ benchmark loads that revision's chat query, uses synthetic data only, compares complete returned rows, and prints timings and `EXPLAIN (ANALYZE, BUFFERS)` plans. Local results measure query execution, not staging request latency. +Run history defaults to 200 rows and permits at most 1,000 rows, including +direct service calls. The UI client requests 200 rows when its caller omits a +limit. Context summaries expand the stored JSON once per returned row, retaining +legacy text conversion and the first-class issue binding. Authorization and +secret redaction still run on the bounded result set. +Agent detail links fetch an older selected run by ID when it is outside the +history page, and verify that it belongs to the current company and agent. + +To compare the real summary and full history queries with an earlier revision: + +```sh +pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-run-history.ts --baseline-ref=1e731520bb +``` + +The fixture has 1,400 synthetic runs with 192 KiB contexts. Both queries return +the same 200 complete rows. This benchmark excludes HTTP and redaction latency. + ## 1. Embedded PostgreSQL — zero config If you don't set `DATABASE_URL`, the server automatically starts an embedded PostgreSQL instance and manages a local data directory. diff --git a/maps/agent-access-map/activity.md b/maps/agent-access-map/activity.md index 40d61378fdd..132833cc35e 100644 --- a/maps/agent-access-map/activity.md +++ b/maps/agent-access-map/activity.md @@ -90,6 +90,9 @@ Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. Gaps: Telemetry/run visibility is checked independently from seeing the source task. +Company heartbeat history defaults to 200 rows and caps explicit limits at 1,000 +in both the route and service. The response remains an array. Scope, visibility, +and secret redaction checks apply to the bounded result set. ## `routine-audit` diff --git a/maps/feature-map/activity.md b/maps/feature-map/activity.md index 7763cad03ed..0429a327057 100644 --- a/maps/feature-map/activity.md +++ b/maps/feature-map/activity.md @@ -68,4 +68,8 @@ the routine rather than treating that component test as a reachable journey. Scope: ui Storybook: Mount AuditRuns selected status/agent/routine, linked task/agent, empty/loading/error/retry and 200-entry limit. Do not invent pagination. Production: GET /companies/{id}/heartbeat-runs and /routines/{id}/runs require company/read/privacy checks; real browser follows context links and confirms the referenced execution. +Company run history defaults to 200 rows and caps explicit limits at 1,000. +Summary contexts omit stored prompts and expand JSON once per returned row. +The array response and run visibility/redaction checks remain in place. +Agent detail links resolve older runs by ID outside the recent history page. Sources: Source: [AuditRuns.tsx](../../ui/src/pages/audit/AuditRuns.tsx), [heartbeats.ts](../../ui/src/api/heartbeats.ts), [routines.ts](../../ui/src/api/routines.ts). Tests: [AuditRuns.test.tsx](../../ui/src/pages/audit/AuditRuns.test.tsx), [heartbeat-run-privacy-routes.test.ts](../../server/src/__tests__/heartbeat-run-privacy-routes.test.ts), [routines-service.test.ts](../../server/src/__tests__/routines-service.test.ts) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f943b187d8e..90246aa6f8b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1496,6 +1496,8 @@ export const PLUGIN_UI_SLOT_TYPES = [ export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number]; export const WORKSPACE_OVERVIEW_DEFAULT_LIMIT = 50; +export const HEARTBEAT_RUN_LIST_DEFAULT_LIMIT = 200; +export const HEARTBEAT_RUN_LIST_MAX_LIMIT = 1000; export const WORKSPACE_OVERVIEW_MAX_LIMIT = 100; export const WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT = 4; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4ffcddf0b29..966ca1458ea 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -584,6 +584,8 @@ export { PLUGIN_API_ROUTE_AUTH_MODES, PLUGIN_API_ROUTE_CHECKOUT_POLICIES, WORKSPACE_OVERVIEW_DEFAULT_LIMIT, + HEARTBEAT_RUN_LIST_DEFAULT_LIMIT, + HEARTBEAT_RUN_LIST_MAX_LIMIT, WORKSPACE_OVERVIEW_MAX_LIMIT, WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT, PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE, diff --git a/scripts/benchmark-run-history.ts b/scripts/benchmark-run-history.ts new file mode 100644 index 00000000000..f80f19b53be --- /dev/null +++ b/scripts/benchmark-run-history.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { randomBytes, randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { companies, agents, createDb, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js"; +import { createHeartbeatRunState } from "../server/src/services/heartbeat/run-state.js"; + +type Query = { sql: string; params: unknown[] }; +const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15) ?? "origin/master"; +const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim(); +const keepAlive = setInterval(() => {}, 1000); +let database: Awaited> | undefined; +let baselineDir: string | undefined; +let db: Db | undefined; +try { + database = await startEmbeddedPostgresTestDatabase("run-history-benchmark-"); + db = createDb(database.connectionString); + const modulePath = "server/src/services/heartbeat/run-state.ts"; + const moduleUrl = new URL(`../${modulePath}`, import.meta.url); + const oldSource = execFileSync("git", ["show", `${baselineRevision}:${modulePath}`], { encoding: "utf8" }); + const rewritten = oldSource.replace(/from "(\.{1,2}\/[^\"]+)"/g, + (_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, moduleUrl)))}`); + baselineDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".run-history-baseline-")); + const baselineFile = path.join(baselineDir, "run-state.ts"); + await fs.writeFile(baselineFile, rewritten); + const baseline = await import(pathToFileURL(baselineFile).href) as typeof import("../server/src/services/heartbeat/run-state.js"); + const companyId = randomUUID(), agentId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Synthetic history", issuePrefix: "BENCH" }); + await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", adapterType: "codex_local" }); + const prompt = randomBytes(144 * 1024).toString("base64"); + await db.$client.unsafe(`INSERT INTO heartbeat_runs (company_id, agent_id, status, created_at, context_snapshot, usage_json, result_json) + SELECT $1::uuid, $2::uuid, 'succeeded', now() + n * interval '1 millisecond', + jsonb_build_object('issueId', 'synthetic-issue-' || n, 'taskId', 'task-' || n, 'taskKey', 'key-' || n, + 'commentId', 'comment-' || n, 'wakeCommentId', 'wake-' || n, 'wakeReason', 'assignment', + 'wakeSource', 'automation', 'wakeTriggerDetail', 'fixture', 'prompt', $3::text), + '{"inputTokens":123,"cachedInputTokens":12,"outputTokens":45,"totalTokens":168}'::jsonb, + '{"summary":"Synthetic result","costUsd":0.03}'::jsonb + FROM generate_series(1, 1400) n`, [companyId, agentId, prompt]); + await db.$client.unsafe("ANALYZE heartbeat_runs"); + + async function capture(factory: typeof createHeartbeatRunState, summary: boolean): Promise { + let query: Query | undefined; + const stop = new Error("captured"); + const captureDb = new Proxy(db!, { get(target, key) { + if (key !== "select") return Reflect.get(target, key); + return (...fields: Parameters) => { + const builder = target.select(...fields), from = builder.from.bind(builder); + builder.from = ((...args: Parameters) => { + const selection = from(...args); + selection.execute = async () => { query = selection.toSQL(); throw stop; }; + return selection; + }) as typeof builder.from; + return builder; + }; + }}); + try { await factory(captureDb).listRuns(companyId, undefined, 200, { summary }); } + catch (error) { if (error !== stop) throw error; } + assert.ok(query); + return query; + } + async function measure(query: Query) { + const samples: number[] = []; + let rows: unknown[] = []; + for (let n = 0; n < 7; n++) { + const start = performance.now(); + rows = await db!.$client.unsafe(query.sql, query.params as never[]).values(); + if (n > 1) samples.push(performance.now() - start); + } + const [plan] = await db!.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params as never[]); + samples.sort((a, b) => a - b); + return { medianMs: samples[2], rows, plan: plan["QUERY PLAN"] }; + } + const results = []; + for (const summary of [true, false]) { + const before = await measure(await capture(baseline.createHeartbeatRunState, summary)); + const after = await measure(await capture(createHeartbeatRunState, summary)); + assert.deepEqual(after.rows, before.rows); + results.push({ summary, equalRows: after.rows.length, baselineMs: before.medianMs, + treatmentMs: after.medianMs, speedup: before.medianMs / after.medianMs, + baselinePlan: before.plan, treatmentPlan: after.plan }); + } + console.log(JSON.stringify({ baselineRevision, runs: 1400, promptBytes: Buffer.byteLength(prompt), results }, null, 2)); +} finally { + try { + await db?.$client.end({ timeout: 0 }); + await database?.cleanup(); + if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true }); + } finally { clearInterval(keepAlive); } +} diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 60605adf327..0fafbdc7849 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -9,6 +9,7 @@ const mockAgentService = vi.hoisted(() => ({ })); const mockHeartbeatService = vi.hoisted(() => ({ + list: vi.fn(async () => []), buildRunOutputSilence: vi.fn(), decorateActiveRunStatus: vi.fn(), getRunIssueSummary: vi.fn(), @@ -277,6 +278,17 @@ async function requestApp( } describe("agent live run routes", () => { + it.each([ + ["", 200], ["?limit=10&agentId=agent-1&summary=true", 10], + ["?limit=99999", 1000], ["?limit=invalid", 200], ["?limit=-10", 1], + ])("bounds company history reads for %s", async (query, limit) => { + const app = await createApp({}); + const response = await requestApp(app, baseUrl => request(baseUrl).get(`/api/companies/company-1/heartbeat-runs${query}`)); + expect(response.status).toBe(200); + expect(mockHeartbeatService.list).toHaveBeenCalledWith("company-1", query.includes("agentId") ? "agent-1" : undefined, + limit, { summary: query.includes("summary=true") }); + expect(mockRunSecretRedactionRegistry.redactForRuns).toHaveBeenCalled(); + }); beforeEach(() => { vi.resetModules(); vi.doUnmock("../services/agents.js"); diff --git a/server/src/__tests__/service-read-disconnect-retry.test.ts b/server/src/__tests__/service-read-disconnect-retry.test.ts index 6ae2515e589..4444d8ac1ae 100644 --- a/server/src/__tests__/service-read-disconnect-retry.test.ts +++ b/server/src/__tests__/service-read-disconnect-retry.test.ts @@ -41,6 +41,7 @@ function fixture(options: { const query = { where(value: SQL) { condition = value; return query; }, innerJoin() { return query; }, + leftJoin() { return query; }, orderBy(...values: SQL[]) { order = values; return query; }, limit(value: number) { limit = value; return query; }, then(resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) { @@ -91,7 +92,7 @@ describe("heartbeat list connection recovery", () => { expect(test.attempts).toHaveLength(3); for (const attempt of test.attempts) { expect(attempt.params).toEqual(options.agent ? [companyId, agentId] : [companyId]); - expect(attempt.limit).toBe(options.limit); + expect(attempt.limit).toBe(options.limit ?? 200); expect(attempt.order).toEqual(['"heartbeat_runs"."created_at" desc']); expect("resultSummary" in attempt.fields).toBe(!options.summary && options.encoding === "UTF8"); expect(attempt.fields).toEqual(test.attempts[0].fields); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 9ada5022a57..b466ecc55e9 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -10,6 +10,7 @@ import { dotRunnerBroker } from "../services/dot-runner-broker.js"; import { publicMcpConfig } from "../services/public-mcp/oauth.js"; import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js"; import { completeConnectionIntentSchema } from "@paperclipai/shared"; +import { HEARTBEAT_RUN_LIST_DEFAULT_LIMIT, HEARTBEAT_RUN_LIST_MAX_LIMIT } from "@paperclipai/shared"; import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js"; import { pipeline } from "node:stream/promises"; import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared"; @@ -6615,7 +6616,9 @@ export function agentRoutes( if (!(await assertRunTelemetryReadAllowed(req, res, companyId))) return; const agentId = req.query.agentId as string | undefined; const limitParam = req.query.limit as string | undefined; - const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined; + const limit = limitParam + ? Math.max(1, Math.min(HEARTBEAT_RUN_LIST_MAX_LIMIT, parseInt(limitParam, 10) || HEARTBEAT_RUN_LIST_DEFAULT_LIMIT)) + : HEARTBEAT_RUN_LIST_DEFAULT_LIMIT; const summary = req.query.summary === "true" || req.query.summary === "1"; const runs = await heartbeat.list(companyId, agentId, limit, { summary }); const readDecisions = await getHeartbeatRunReadDecisions(db, access, req.actor, runs); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index d84b733334a..9895e95065d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -7366,7 +7366,14 @@ registry.registerPath({ path: "/api/companies/{companyId}/heartbeat-runs", tags: ["runs"], summary: "List heartbeat runs for a company", - request: { params: z.object({ companyId: z.string() }) }, + request: { + params: z.object({ companyId: z.string() }), + query: z.object({ + agentId: z.string().optional(), + limit: z.coerce.number().int().min(1).max(1000).default(200), + summary: z.enum(["true", "false", "1", "0"]).optional(), + }), + }, responses: { 200: r.ok(), 401: r.unauthorized }, }); diff --git a/server/src/services/heartbeat/run-state.test.ts b/server/src/services/heartbeat/run-state.test.ts index b5194144be4..2a4b2c590e1 100644 --- a/server/src/services/heartbeat/run-state.test.ts +++ b/server/src/services/heartbeat/run-state.test.ts @@ -30,6 +30,7 @@ function readingDatabase(serverEncoding: string, ...results: unknown[][]) { const rows = results.shift() ?? []; const query = { from: () => query, + leftJoin: () => query, where: (condition: SQL) => { scopes.push(new PgDialect().sqlToQuery(condition).params); return query; @@ -120,6 +121,50 @@ describePostgres("heartbeat run state database wiring", () => { return { companyId, agent, issue, state: extracted.createHeartbeatRunState(db) }; } + it("bounds history at the service boundary and keeps company and agent scope", async () => { + const { companyId, agent, state } = await fixture(); + await db.execute(sql`insert into heartbeat_runs (company_id, agent_id, created_at) + select ${companyId}::uuid, ${agent.id}::uuid, '2026-01-01'::timestamptz + n * interval '1 second' + from generate_series(1, 1005) n`); + const foreign = await fixture(); + await db.insert(heartbeatRuns).values({ companyId: foreign.companyId, agentId: foreign.agent.id }); + const rows = await state.listRuns(companyId); + expect(rows).toHaveLength(200); + expect(rows.every(row => row.companyId === companyId && row.agentId === agent.id)).toBe(true); + expect(rows[0].createdAt.getTime()).toBeGreaterThan(rows[199].createdAt.getTime()); + expect(await state.listRuns(companyId, agent.id, 10000)).toHaveLength(1000); + expect(await state.listRuns(companyId, agent.id, 1.9)).toHaveLength(1); + expect(await state.listRuns(companyId, agent.id, -10)).toHaveLength(1); + expect(await state.listRuns(companyId, agent.id, NaN)).toHaveLength(200); + expect(await state.listRuns(companyId, foreign.agent.id)).toEqual([]); + }); + + it("preserves legacy context text, native issue precedence and summary privacy", async () => { + const { companyId, agent, issue, state } = await fixture(); + const contexts: unknown[] = [null, [], "legacy", 3, true, {}, { + issueId: "legacy-issue", taskId: 17, taskKey: { nested: true }, commentId: ["one", 2], + wakeCommentId: null, wakeReason: false, wakeSource: " ", wakeTriggerDetail: "héllo\nworld", + secret: "never return this", prompt: randomBytes(96 * 1024).toString("hex"), + }]; + for (const [index, contextSnapshot] of contexts.entries()) { + const [run] = await db.insert(heartbeatRuns).values({ + companyId, agentId: agent.id, issueId: index === contexts.length - 1 ? issue.id : null, + contextSnapshot: contextSnapshot as Record, + }).returning(); + const expected = (await db.execute(sql`select coalesce(issue_id::text, context_snapshot->>'issueId') as "issueId", + context_snapshot->>'taskId' as "taskId", context_snapshot->>'taskKey' as "taskKey", + context_snapshot->>'commentId' as "commentId", context_snapshot->>'wakeCommentId' as "wakeCommentId", + context_snapshot->>'wakeReason' as "wakeReason", context_snapshot->>'wakeSource' as "wakeSource", + context_snapshot->>'wakeTriggerDetail' as "wakeTriggerDetail" from heartbeat_runs where id = ${run.id}::uuid`))[0]; + for (const summary of [false, true]) { + const actual = (await state.listRuns(companyId, agent.id, 100, { summary })).find(row => row.id === run.id)!; + expect(actual.contextSnapshot).toEqual(extracted.summarizeHeartbeatRunContextSnapshot(expected)); + expect(actual.contextSnapshot ?? {}).not.toHaveProperty("secret"); + expect(actual.contextSnapshot ?? {}).not.toHaveProperty("prompt"); + } + } + }); + it("preserves controller lease cancellation when a large result is bounded", async () => { const { companyId, agent, state } = await fixture(); const cancellation = { diff --git a/server/src/services/heartbeat/run-state.ts b/server/src/services/heartbeat/run-state.ts index b10823270cd..0f79a984a4f 100644 --- a/server/src/services/heartbeat/run-state.ts +++ b/server/src/services/heartbeat/run-state.ts @@ -50,6 +50,7 @@ import { type SessionCompactionPolicy, } from "@paperclipai/adapter-utils"; import { isUuidLike } from "@paperclipai/shared"; +import { HEARTBEAT_RUN_LIST_DEFAULT_LIMIT, HEARTBEAT_RUN_LIST_MAX_LIMIT } from "@paperclipai/shared"; import { isUnsafeSessionWorkspaceCwd } from "../session-workspace-cwd.js"; export const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = @@ -130,18 +131,25 @@ const heartbeatRunSummaryListColumns = { resultJson: sql | null>`NULL`.as("resultJson"), } as const; +const heartbeatRunListContextRecord = sql`lateral jsonb_to_record( + case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object' + then ${heartbeatRuns.contextSnapshot} else '{}'::jsonb end +) as run_context("issueId" text, "taskId" text, "taskKey" text, + "commentId" text, "wakeCommentId" text, "wakeReason" text, + "wakeSource" text, "wakeTriggerDetail" text)`; + const heartbeatRunListContextColumns = { contextIssueId: sql`coalesce( ${heartbeatRuns.issueId}::text, - ${heartbeatRuns.contextSnapshot} ->> 'issueId' + run_context."issueId" )`.as("contextIssueId"), - contextTaskId: sql`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"), - contextTaskKey: sql`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"), - contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), - contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), - contextWakeReason: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"), - contextWakeSource: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"), - contextWakeTriggerDetail: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"), + contextTaskId: sql`run_context."taskId"`.as("contextTaskId"), + contextTaskKey: sql`run_context."taskKey"`.as("contextTaskKey"), + contextCommentId: sql`run_context."commentId"`.as("contextCommentId"), + contextWakeCommentId: sql`run_context."wakeCommentId"`.as("contextWakeCommentId"), + contextWakeReason: sql`run_context."wakeReason"`.as("contextWakeReason"), + contextWakeSource: sql`run_context."wakeSource"`.as("contextWakeSource"), + contextWakeTriggerDetail: sql`run_context."wakeTriggerDetail"`.as("contextWakeTriggerDetail"), } as const; const heartbeatRunListResultColumns = { @@ -1693,6 +1701,7 @@ export function createHeartbeatRunState(db: Db) { }, ) .from(heartbeatRuns) + .leftJoin(heartbeatRunListContextRecord, sql`true`) .where( agentId ? and( @@ -1703,7 +1712,10 @@ export function createHeartbeatRunState(db: Db) { ) .orderBy(desc(heartbeatRuns.createdAt)); - return limit ? await query.limit(limit) : await query; + const boundedLimit = Number.isFinite(limit) + ? Math.max(1, Math.min(HEARTBEAT_RUN_LIST_MAX_LIMIT, Math.trunc(limit!))) + : HEARTBEAT_RUN_LIST_DEFAULT_LIMIT; + return await query.limit(boundedLimit); }); return rows.map((row) => { const { diff --git a/ui/src/api/heartbeats.test.ts b/ui/src/api/heartbeats.test.ts index 4de7d5a69f7..ff517cf4342 100644 --- a/ui/src/api/heartbeats.test.ts +++ b/ui/src/api/heartbeats.test.ts @@ -31,6 +31,11 @@ describe("heartbeatsApi.list", () => { expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/heartbeat-runs?limit=200&summary=true"); }); + it("bounds an omitted limit for agent history consumers", async () => { + await heartbeatsApi.list("company-1", "agent-1"); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/heartbeat-runs?agentId=agent-1&limit=200"); + }); + it("keeps full row requests as the default for run-history screens", async () => { await heartbeatsApi.list("company-1", "agent-1", 25); diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 84c7990f664..8d565c28e03 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,4 +1,5 @@ import type { AgentAppearance } from "@paperclipai/shared"; +import { HEARTBEAT_RUN_LIST_DEFAULT_LIMIT } from "@paperclipai/shared"; import type { IssueRecoveryAction } from "@paperclipai/shared"; import type { HeartbeatRun, @@ -120,7 +121,7 @@ export const heartbeatsApi = { list: ( companyId: string, agentId?: string, - limit?: number, + limit = HEARTBEAT_RUN_LIST_DEFAULT_LIMIT, options: HeartbeatRunListOptions = {}, ) => { const searchParams = new URLSearchParams(); diff --git a/ui/src/hooks/useSelectedHeartbeatRun.test.tsx b/ui/src/hooks/useSelectedHeartbeatRun.test.tsx new file mode 100644 index 00000000000..f4dd63fcb51 --- /dev/null +++ b/ui/src/hooks/useSelectedHeartbeatRun.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { HeartbeatRun } from "@paperclipai/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useSelectedHeartbeatRun } from "./useSelectedHeartbeatRun"; + +const { get } = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("../api/heartbeats", () => ({ heartbeatsApi: { get } })); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +afterEach(() => { get.mockReset(); }); + +const row = (id: string, extra = {}) => ({ id, companyId: "company-1", agentId: "agent-1", ...extra }) as HeartbeatRun; +function Probe({ runs, id }: { runs: HeartbeatRun[]; id: string | null }) { + const selected = useSelectedHeartbeatRun(runs, id, "company-1", "agent-1"); + return
+ {selected.run?.id ?? (selected.error ? "failed" : selected.loading ? "loading" : "unavailable")} + +
; +} + +async function harness(runs: HeartbeatRun[], id: string | null) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const element = document.createElement("div"); document.body.append(element); + const root = createRoot(element); + const render = async (nextId = id) => act(async () => root.render()); + await render(); + return { element, render, dispose: async () => { await act(async () => root.unmount()); client.clear(); element.remove(); } }; +} + +describe("bounded history selection", () => { + it("opens a listed run without an additional lookup", async () => { + const h = await harness([row("recent")], "recent"); + try { expect(h.element.querySelector("output")?.textContent).toBe("recent"); expect(get).not.toHaveBeenCalled(); } + finally { await h.dispose(); } + }); + it.each([{ runs: [] }, { runs: [row("recent")] }])("opens an older link outside the history page (%#)", async ({ runs }) => { + get.mockResolvedValue(row("older")); + const h = await harness(runs, "older"); + try { + await vi.waitFor(async () => { await act(async () => {}); expect(h.element.querySelector("output")?.textContent).toBe("older"); }); + expect(get).toHaveBeenCalledWith("older"); + } finally { await h.dispose(); } + }); + it.each([{ companyId: "other" }, { agentId: "other" }, { id: "other" }])("rejects a mismatched fetched run (%j)", async extra => { + get.mockResolvedValue(row("older", extra)); + const h = await harness([], "older"); + try { await vi.waitFor(async () => { await act(async () => {}); expect(h.element.querySelector("output")?.textContent).toBe("unavailable"); }); } + finally { await h.dispose(); } + }); + it("keeps late responses attached to their own navigation and retries errors", async () => { + let finish!: (run: HeartbeatRun) => void; + get.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + get.mockRejectedValueOnce(new Error("temporary failure")); + const h = await harness([], "older"); + try { + await h.render("newer"); + await vi.waitFor(async () => { await act(async () => {}); expect(h.element.querySelector("output")?.textContent).toBe("failed"); }); + await act(async () => finish(row("older"))); + expect(h.element.querySelector("output")?.textContent).toBe("failed"); + get.mockResolvedValueOnce(row("newer")); + await act(async () => h.element.querySelector("button")!.click()); + await vi.waitFor(async () => { await act(async () => {}); expect(h.element.querySelector("output")?.textContent).toBe("newer"); }); + } finally { await h.dispose(); } + }); +}); diff --git a/ui/src/hooks/useSelectedHeartbeatRun.ts b/ui/src/hooks/useSelectedHeartbeatRun.ts new file mode 100644 index 00000000000..10ad5997de5 --- /dev/null +++ b/ui/src/hooks/useSelectedHeartbeatRun.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import type { HeartbeatRun } from "@paperclipai/shared"; +import { heartbeatsApi } from "../api/heartbeats"; +import { queryKeys } from "../lib/queryKeys"; + +export function useSelectedHeartbeatRun( + runs: HeartbeatRun[], + selectedRunId: string | null, + companyId: string, + agentId: string, +) { + const listedRun = runs.find(run => run.id === selectedRunId) ?? null; + const query = useQuery({ + queryKey: queryKeys.runDetail(selectedRunId ?? ""), + queryFn: () => heartbeatsApi.get(selectedRunId!), + enabled: Boolean(selectedRunId && !listedRun), + }); + const fetchedRun = query.data?.id === selectedRunId && query.data.companyId === companyId && query.data.agentId === agentId ? query.data : null; + return { run: listedRun ?? fetchedRun, loading: query.isPending, error: query.error, retry: query.refetch }; +} diff --git a/ui/src/pages/AgentDetail.history.test.tsx b/ui/src/pages/AgentDetail.history.test.tsx new file mode 100644 index 00000000000..1f03ee06acc --- /dev/null +++ b/ui/src/pages/AgentDetail.history.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import type { HeartbeatRun } from "@paperclipai/shared"; +import { afterEach, expect, it, vi } from "vitest"; +import { RunsTab } from "./AgentDetail"; +import { RunsTab as ProductionRunsTab } from "./AgentDetail.production"; +import { ToastProvider } from "../context/ToastContext"; +import { PanelProvider } from "../context/PanelContext"; + +const { get, mobile } = vi.hoisted(() => ({ get: vi.fn(), mobile: { value: false } })); +vi.mock("../api/heartbeats", () => ({ heartbeatsApi: { get, events: async () => [], workspaceOperations: async () => [] } })); +vi.mock("../api/client", () => ({ api: { get: async () => [] } })); +vi.mock("../api/access", () => ({ accessApi: { getCurrentBoardAccess: async () => ({ companyIds: ["company-1"], memberships: [] }) } })); +vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: mobile.value }) })); +vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", issuePrefix: "TEST" }, companies: [] }) })); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +afterEach(() => { get.mockReset(); }); + +it.each([ + { Tab: RunsTab, isMobile: false }, { Tab: ProductionRunsTab, isMobile: false }, + { Tab: RunsTab, isMobile: true }, { Tab: ProductionRunsTab, isMobile: true }, +])("renders an older selected run when history is empty (%#)", async ({ Tab, isMobile }) => { + mobile.value = isMobile; + const run = { id: "older-run", companyId: "company-1", agentId: "agent-1", status: "succeeded", invocationSource: "manual", + createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", usageJson: null, resultJson: null, + contextSnapshot: null, exitCode: 0, error: null, logRef: null, startedAt: null, finishedAt: null } as unknown as HeartbeatRun; + get.mockResolvedValue(run); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const element = document.createElement("div"); document.body.append(element); + const root = createRoot(element); + try { + await act(async () => root.render( + + )); + await vi.waitFor(async () => { await act(async () => {}); expect(element.textContent).toContain("succeeded"); }); + expect(element.textContent).not.toContain("No runs yet."); + expect(get).toHaveBeenCalledWith("older-run"); + } finally { await act(async () => root.unmount()); client.clear(); element.remove(); } +}); diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx index 34ed6afe10f..1258faa6955 100644 --- a/ui/src/pages/AgentDetail.production.tsx +++ b/ui/src/pages/AgentDetail.production.tsx @@ -1,3 +1,4 @@ +import { useSelectedHeartbeatRun } from "../hooks/useSelectedHeartbeatRun"; import { AgentCharacter } from "../components/AgentCharacter"; import { characterStateForAgent } from "@paperclipai/shared"; import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks"; @@ -3153,7 +3154,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect ); } -function RunsTab({ +export function RunsTab({ runs, companyId, agentId, @@ -3172,10 +3173,6 @@ function RunsTab({ }) { const { isMobile } = useSidebar(); - if (runs.length === 0) { - return

No runs yet.

; - } - // Sort by created descending const sorted = [...runs].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() @@ -3183,7 +3180,16 @@ function RunsTab({ // On mobile, don't auto-select so the list shows first; on desktop, auto-select latest const effectiveRunId = isMobile ? selectedRunId : (selectedRunId ?? sorted[0]?.id ?? null); - const selectedRun = sorted.find((r) => r.id === effectiveRunId) ?? null; + const selection = useSelectedHeartbeatRun(sorted, effectiveRunId, companyId, agentId); + const selectedRun = selection.run; + if (selectedRunId && !selectedRun) { + if (selection.loading) return

Loading run…

; + return
+

{selection.error instanceof Error ? selection.error.message : "This run is unavailable for this agent."}

+ {selection.error && } +
; + } + if (runs.length === 0 && !selectedRun) return

No runs yet.

; // Mobile: show either run list OR run detail with back button if (isMobile) { diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 31a99452e23..8666d4d6712 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,3 +1,4 @@ +import { useSelectedHeartbeatRun } from "../hooks/useSelectedHeartbeatRun"; import { grokBotApi } from "@/api/grokBot"; import { GrokBotWorkSummary } from "@/components/GrokBotWorkSummary"; import { PrimaryAgentIndicator, SetPrimaryAgentButton } from "@/components/primary-agent/PrimaryAgentPresentation"; @@ -3315,7 +3316,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect ); } -function RunsTab({ +export function RunsTab({ runs, companyId, agentId, @@ -3334,10 +3335,6 @@ function RunsTab({ }) { const { isMobile } = useSidebar(); - if (runs.length === 0) { - return

No runs yet.

; - } - // Sort by created descending const sorted = [...runs].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() @@ -3345,7 +3342,16 @@ function RunsTab({ // On mobile, don't auto-select so the list shows first; on desktop, auto-select latest const effectiveRunId = isMobile ? selectedRunId : (selectedRunId ?? sorted[0]?.id ?? null); - const selectedRun = sorted.find((r) => r.id === effectiveRunId) ?? null; + const selection = useSelectedHeartbeatRun(sorted, effectiveRunId, companyId, agentId); + const selectedRun = selection.run; + if (selectedRunId && !selectedRun) { + if (selection.loading) return

Loading run…

; + return
+

{selection.error instanceof Error ? selection.error.message : "This run is unavailable for this agent."}

+ {selection.error && } +
; + } + if (runs.length === 0 && !selectedRun) return

No runs yet.

; // Mobile: show either run list OR run detail with back button if (isMobile) {