perf(runs): bound history and reduce repeated context reads (#16286)

## Thinking Path

> - Paperclip helps operators manage AI agents and inspect their work.
> - Run history supplies recent execution records to the board and API
clients.
> - The list repeats eight reads of a large stored JSON context.
> - A caller can also omit the limit and read the complete history.
> - This pull request expands context fields once and bounds every list
read.
> - The benefit is lower database cost and bounded redaction work.

## Linked Issues or Issue Description

Refs #958. Related community reports: #13962 and #10631. This is an
independent implementation. It does not use their commits or code. It
covers the history bounds reported in both. Process memory monitoring
from #10631 is outside this change.

**What happened?**

The run-history query repeatedly extracts fields from large context
JSON. Requests without a limit also load every historical run before
authorization and redaction. Accumulated staging query statistics showed
271 ms for summary history and another 58 ms for its redaction lookup.

**What did you expect to happen?**

Normal reads must use a bounded recent history. Context projection must
preserve the existing values and privacy behavior with less repeated
database work.

**Steps to reproduce**

Run the disposable benchmark below against the baseline revision. It
creates 1,400 synthetic runs with 192 KiB contexts. It compares the
exact service SQL and every returned column for both summary and full
history.

## What Changed

- Expand context fields with one lateral record projection. Preserve
non-object legacy contexts and the first-class issue binding.
- Enforce a 200-row default and a 1,000-row maximum in the route and
service.
- Apply the default in the shared UI client, including both agent detail
implementations.
- Preserve response arrays, authorization, result projection and secret
redaction.
- Fetch older linked runs by ID in both agent pages. Check company and
agent scope, navigation races and visible retry errors.
- Update OpenAPI, data-path documentation and capability maps.
- Add real PostgreSQL and HTTP regressions plus a reproducible
benchmark.

## Verification

- Local benchmark: summary history 37.72 ms to 19.34 ms; full history
37.84 ms to 19.01 ms. All 200 complete positional rows match in each
comparison. These are synthetic local SQL timings, excluding HTTP and
redaction.
- History, route, privacy and redaction suites: 160 server tests passed
across six files. Twenty-two UI API, selection, run-panel and
log-visibility tests passed. Both agent-page variants render an older
linked run with an empty history page on desktop and mobile.
- `pnpm -r typecheck` and `pnpm build` passed.
- Map validation and `git diff --check` passed.
- All current-head CI and security checks passed, including the full
general and serialized suites, browser shards, Runner verification and
build. Greptile reviewed the exact commit at 5/5 with no remaining
findings.
- The full local sweep began before the final compatibility fixes. It
was stopped after the final commits passed complete CI. The focused
local suites listed above passed on the final code. Before interruption,
the broader local sweep passed 31,743 tests across the general server,
UI and several package groups, with no reported failures. The full local
command is not claimed as a completed pass.

```sh
pnpm --filter @paperclipai/server exec vitest run src/services/heartbeat/run-state.test.ts src/__tests__/heartbeat-list.test.ts src/__tests__/agent-live-run-routes.test.ts src/__tests__/run-secret-redaction.test.ts src/__tests__/heartbeat-run-privacy-routes.test.ts
pnpm --filter @paperclipai/ui exec vitest run src/api/heartbeats.test.ts
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-run-history.ts --baseline-ref=1e731520bb
```

## Risks

- Clients that relied on an unbounded array now receive 200 rows by
default. Explicit limits allow up to 1,000. This change does not add
pagination.
- Legacy context conversion must stay identical. Real database
regressions cover null, array, scalar, nested values and native issue
precedence.
- No schema migration or persistent cache is introduced. Redaction and
access checks remain active.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, shell
tools and local code execution. The runtime does not expose a more
specific backend model ID or context-window size.

## 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
- [x] All Paperclip CI gates are green
- [x] 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 <[email protected]>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-14 10:14:41 -05:00
1 parent 03fcd3c1fb
commit ebeac833a6
19 files changed
+371 -25

No files matched your search

+17
View File
@@ -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.
+3
View File
@@ -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`
+4
View File
@@ -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)
+2
View File
@@ -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;
+2
View File
@@ -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,
+91
View File
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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<Query> {
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<Db["select"]>) => {
const builder = target.select(...fields), from = builder.from.bind(builder);
builder.from = ((...args: Parameters<typeof from>) => {
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); }
}
@@ -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");
@@ -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);
+4 -1
View File
@@ -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);
+8 -1
View File
@@ -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 },
});
@@ -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<string, unknown>,
}).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 = {
+21 -9
View File
@@ -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<Record<string, unknown> | 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<string | null>`coalesce(
${heartbeatRuns.issueId}::text,
${heartbeatRuns.contextSnapshot} ->> 'issueId'
run_context."issueId"
)`.as("contextIssueId"),
contextTaskId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"),
contextTaskKey: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"),
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
contextWakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"),
contextWakeReason: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"),
contextWakeSource: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"),
contextWakeTriggerDetail: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"),
contextTaskId: sql<string | null>`run_context."taskId"`.as("contextTaskId"),
contextTaskKey: sql<string | null>`run_context."taskKey"`.as("contextTaskKey"),
contextCommentId: sql<string | null>`run_context."commentId"`.as("contextCommentId"),
contextWakeCommentId: sql<string | null>`run_context."wakeCommentId"`.as("contextWakeCommentId"),
contextWakeReason: sql<string | null>`run_context."wakeReason"`.as("contextWakeReason"),
contextWakeSource: sql<string | null>`run_context."wakeSource"`.as("contextWakeSource"),
contextWakeTriggerDetail: sql<string | null>`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 {
+5
View File
@@ -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);
+2 -1
View File
@@ -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();
@@ -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 <div>
<output>{selected.run?.id ?? (selected.error ? "failed" : selected.loading ? "loading" : "unavailable")}</output>
<button onClick={() => { void selected.retry(); }}>Retry</button>
</div>;
}
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(<QueryClientProvider client={client}><Probe runs={runs} id={nextId} /></QueryClientProvider>));
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(); }
});
});
+20
View File
@@ -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 };
}
+42
View File
@@ -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(<QueryClientProvider client={client}><MemoryRouter><PanelProvider><ToastProvider>
<Tab runs={[]} companyId="company-1" agentId="agent-1" agentRouteId="agent-1" selectedRunId="older-run" adapterType="codex_local" adapterConfig={{}} />
</ToastProvider></PanelProvider></MemoryRouter></QueryClientProvider>));
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(); }
});
+12 -6
View File
@@ -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 <p className="text-sm text-muted-foreground">No runs yet.</p>;
}
// 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 <p className="text-sm text-muted-foreground">Loading run…</p>;
return <div role="alert">
<p className="text-sm text-destructive">{selection.error instanceof Error ? selection.error.message : "This run is unavailable for this agent."}</p>
{selection.error && <Button variant="outline" onClick={() => { void selection.retry(); }}>Try again</Button>}
</div>;
}
if (runs.length === 0 && !selectedRun) return <p className="text-sm text-muted-foreground">No runs yet.</p>;
// Mobile: show either run list OR run detail with back button
if (isMobile) {
+12 -6
View File
@@ -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 <p className="text-sm text-muted-foreground">No runs yet.</p>;
}
// 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 <p className="text-sm text-muted-foreground">Loading run…</p>;
return <div role="alert">
<p className="text-sm text-destructive">{selection.error instanceof Error ? selection.error.message : "This run is unavailable for this agent."}</p>
{selection.error && <Button variant="outline" onClick={() => { void selection.retry(); }}>Try again</Button>}
</div>;
}
if (runs.length === 0 && !selectedRun) return <p className="text-sm text-muted-foreground">No runs yet.</p>;
// Mobile: show either run list OR run detail with back button
if (isMobile) {