From 88ce6d35756c9b1c136944eae79e797d12bd958a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:28:54 -0500 Subject: [PATCH] Speed up issue detail payloads (#9125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators spend a lot of time in issue detail pages and agent activity views while supervising work > - Those views were receiving large embedded project, workspace, runtime-service, and heartbeat context payloads > - Large payloads make issue comments and page loads slower, especially on active issues with workspaces and runtime metadata > - This pull request trims the issue detail and activity ledger response shapes to the fields those views need > - The benefit is faster issue detail loading without changing the underlying project, workspace, or run persistence model ## Linked Issues or Issue Description No public GitHub issue was found for this exact problem, so this PR describes the bug inline using the bug report template fields. ### Pre-submission checklist - [x] I have searched existing open and closed issues and this is not a duplicate. - [x] I am on the latest released version of Paperclip (or can reproduce on `master`). - [x] I have confirmed the error originates in Paperclip itself — not in my agent adapter, API provider, or local configuration. ### What happened? Issue detail and related activity responses could include bulky embedded metadata such as project environment values, workspace metadata, stopped runtime services, and heartbeat context snapshots. On active issues with workspaces and long activity history, that makes issue comments and page loads slower than needed. ### Expected behavior Issue detail endpoints should return bounded, UI-oriented embeds that avoid shipping large or sensitive internal blobs when the full object graph is not needed. ### Steps to reproduce 1. Create or open an issue with a project workspace and execution workspace. 2. Ensure the workspace has runtime services and heartbeat runs with context snapshots. 3. Inspect `GET /api/issues/:id` and the issue activity ledger payloads. 4. Observe that the response includes large embedded project/workspace/runtime/run fields unrelated to rendering the issue detail page. ### Paperclip version or commit Reproduced against current `master` lineage before this change. ### Deployment mode Local dev / server API behavior. ### Installation method Built from source (`pnpm dev` / `pnpm build`). ### Agent adapter(s) involved Not adapter-specific (core API payload shape). ### Database mode Not database-related; no migration. ### Access context Board and agent-facing issue detail consumers can both benefit from smaller payloads. ### Node.js version Not version-specific. ### Operating system Not OS-specific. ### Relevant logs or output Not applicable. ### Relevant config (if applicable) Not applicable. ### Additional context Related search: - Searched public GitHub issues for `currentExecutionWorkspace metadata runtimeServices issue detail`; no matching issue found. - Searched public GitHub PRs for `compact currentExecutionWorkspace metadata runtimeServices`; no matching PR found. ### Privacy checklist - [x] I have reviewed all pasted output for PII (usernames, file paths, API keys, tokens, company names) and redacted where necessary. ## What Changed - Added compact response shaping for issue detail project, project workspace, execution workspace, and runtime-service embeds. - Dropped large project `env`, workspace `metadata` / embedded runtime service lists, execution workspace `metadata`, and non-active runtime services from `GET /api/issues/:id` responses. - Removed heartbeat `contextSnapshot` from the activity ledger query result. - Added focused route and activity-service tests covering the compact response shape. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-goal-context-routes.test.ts server/src/__tests__/activity-service.test.ts --no-file-parallelism --maxWorkers=1` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check public/master..HEAD` - Confirmed the branch is based on current `paperclipai/paperclip:master` and contains no `pnpm-lock.yaml` or `.github/workflows` changes. ## Risks Low to medium risk. The persisted data model is unchanged, but consumers relying on the full embedded project/workspace/runtime metadata from `GET /api/issues/:id` will now need to fetch the dedicated resource endpoint instead of depending on the issue detail payload. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex coding agent with repository file access, shell command execution, GitHub connector usage, and local test execution. Context window and exact hosted model variant are not exposed in this runtime. ## 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 --- server/src/__tests__/activity-service.test.ts | 1 + .../issues-goal-context-routes.test.ts | 142 ++++++++++++++++++ server/src/routes/issues.ts | 137 ++++++++++++++++- server/src/services/activity.ts | 1 - 4 files changed, 278 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts index 6948d8385f..df7d841b9a 100644 --- a/server/src/__tests__/activity-service.test.ts +++ b/server/src/__tests__/activity-service.test.ts @@ -212,6 +212,7 @@ describeEmbeddedPostgres("activity service", () => { lastUsefulActionAt: new Date("2026-04-18T19:59:00.000Z"), nextAction: "Review the completed output.", }); + expect(runs[0]).not.toHaveProperty("contextSnapshot"); }); it("backfills missing liveness for completed issue runs before returning the ledger", async () => { diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index e68bb0a379..ac755efb7f 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -279,6 +279,148 @@ describe.sequential("issue goal context routes", () => { expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled(); }); + it("keeps GET /issues/:id project and workspace embeds compact for fast detail loads", async () => { + const workspaceId = "55555555-5555-4555-8555-555555555555"; + const runtimeServiceBase = { + companyId: "company-1", + projectId: legacyProjectLinkedIssue.projectId, + projectWorkspaceId: "workspace-primary", + executionWorkspaceId: workspaceId, + issueId: legacyProjectLinkedIssue.id, + scopeType: "execution_workspace", + scopeId: workspaceId, + serviceName: "Dev server", + lifecycle: "shared", + reuseKey: "dev-server", + command: "pnpm dev", + cwd: "/tmp/company-1/project-1", + port: 3100, + url: "http://localhost:3100", + provider: "local_process", + providerRef: "pid:123", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: new Date("2026-03-24T12:00:00Z"), + startedAt: new Date("2026-03-24T12:00:00Z"), + stoppedAt: null, + healthStatus: "healthy", + configIndex: 0, + metadata: { huge: "runtime metadata should not be embedded" }, + }; + mockIssueService.getById.mockResolvedValueOnce({ + ...legacyProjectLinkedIssue, + executionWorkspaceId: workspaceId, + }); + mockProjectService.getById.mockResolvedValueOnce({ + ...(await mockProjectService.getById()), + env: { API_KEY: { type: "plain", value: "should-not-ship" } }, + workspaces: [ + { + id: "workspace-primary", + companyId: "company-1", + projectId: legacyProjectLinkedIssue.projectId, + name: "Main", + sourceType: "local_path", + cwd: "/tmp/company-1/project-1", + repoUrl: null, + repoRef: "master", + defaultRef: "master", + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: { huge: "project workspace metadata should not be embedded" }, + runtimeConfig: { services: [{ name: "Dev server", command: "pnpm dev" }] }, + runtimeServices: [{ ...runtimeServiceBase, id: "project-service-stopped", status: "stopped" }], + isPrimary: true, + createdAt: new Date("2026-03-20T00:00:00Z"), + updatedAt: new Date("2026-03-20T00:00:00Z"), + }, + ], + primaryWorkspace: { + id: "workspace-primary", + companyId: "company-1", + projectId: legacyProjectLinkedIssue.projectId, + name: "Main", + sourceType: "local_path", + cwd: "/tmp/company-1/project-1", + repoUrl: null, + repoRef: "master", + defaultRef: "master", + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: { huge: "primary workspace metadata should not be embedded" }, + runtimeConfig: { services: [{ name: "Dev server", command: "pnpm dev" }] }, + runtimeServices: [{ ...runtimeServiceBase, id: "primary-service-stopped", status: "stopped" }], + isPrimary: true, + createdAt: new Date("2026-03-20T00:00:00Z"), + updatedAt: new Date("2026-03-20T00:00:00Z"), + }, + }); + mockExecutionWorkspaceService.getById.mockResolvedValueOnce({ + id: workspaceId, + companyId: "company-1", + projectId: legacyProjectLinkedIssue.projectId, + projectWorkspaceId: "workspace-primary", + sourceIssueId: legacyProjectLinkedIssue.id, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "PAP-581-workspace", + status: "active", + cwd: "/tmp/company-1/project-1", + repoUrl: null, + baseRef: "master", + branchName: "PAP-581-workspace", + providerType: "local", + providerRef: "/tmp/company-1/project-1", + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date("2026-03-24T12:00:00Z"), + openedAt: new Date("2026-03-24T12:00:00Z"), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: { + environmentId: "env-1", + provisionCommand: "bash setup.sh", + teardownCommand: null, + cleanupCommand: null, + workspaceRuntime: { services: [{ name: "Dev server", command: "pnpm dev" }] }, + desiredState: null, + serviceStates: null, + }, + metadata: { huge: "execution workspace metadata should not be embedded" }, + runtimeServices: [ + { ...runtimeServiceBase, id: "service-running", status: "running" }, + { ...runtimeServiceBase, id: "service-stopped", status: "stopped" }, + ], + createdAt: new Date("2026-03-24T12:00:00Z"), + updatedAt: new Date("2026-03-24T12:00:00Z"), + }); + + const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111"); + + expect(res.status).toBe(200); + expect(res.body.project.env).toBeNull(); + expect(res.body.project.workspaces[0]).not.toHaveProperty("metadata"); + expect(res.body.project.workspaces[0]).not.toHaveProperty("runtimeServices"); + expect(res.body.project.primaryWorkspace).not.toHaveProperty("metadata"); + expect(res.body.project.primaryWorkspace).not.toHaveProperty("runtimeServices"); + expect(res.body.currentExecutionWorkspace.metadata).toBeNull(); + expect(res.body.currentExecutionWorkspace.runtimeServices).toHaveLength(1); + expect(res.body.currentExecutionWorkspace.runtimeServices[0]).toMatchObject({ + id: "service-running", + status: "running", + url: "http://localhost:3100", + }); + expect(res.body.currentExecutionWorkspace.runtimeServices[0]).not.toHaveProperty("metadata"); + }); + it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => { const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e318974cd5..4809a71c73 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -79,8 +79,10 @@ import { type IssueWakeDiagnosticsResponse, type IssueRelationIssueSummary, type IssueWatchdogDiscoveryKind, + type ProjectWorkspace, type SourceTrustMetadata, type SuccessfulRunHandoffState, + type WorkspaceRuntimeService, } from "@paperclipai/shared"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; @@ -3837,6 +3839,137 @@ export function issueRoutes( return { project, goal: null }; } + function compactIssueProjectWorkspace(workspace: ProjectWorkspace | null | undefined) { + if (!workspace) return null; + return { + id: workspace.id, + companyId: workspace.companyId, + projectId: workspace.projectId, + name: workspace.name, + sourceType: workspace.sourceType, + cwd: workspace.cwd, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + defaultRef: workspace.defaultRef, + visibility: workspace.visibility, + setupCommand: workspace.setupCommand, + cleanupCommand: workspace.cleanupCommand, + remoteProvider: workspace.remoteProvider, + remoteWorkspaceRef: workspace.remoteWorkspaceRef, + sharedWorkspaceKey: workspace.sharedWorkspaceKey, + runtimeConfig: workspace.runtimeConfig, + isPrimary: workspace.isPrimary, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }; + } + + function compactIssueProject(project: Awaited>["project"]) { + if (!project) return null; + return { + id: project.id, + companyId: project.companyId, + urlKey: project.urlKey, + goalId: project.goalId, + goalIds: project.goalIds, + goals: project.goals, + name: project.name, + description: project.description, + status: project.status, + leadAgentId: project.leadAgentId, + targetDate: project.targetDate, + color: project.color, + icon: project.icon, + env: null, + pauseReason: project.pauseReason, + pausedAt: project.pausedAt, + executionWorkspacePolicy: project.executionWorkspacePolicy, + codebase: project.codebase, + workspaces: (project.workspaces ?? []).map(compactIssueProjectWorkspace), + primaryWorkspace: compactIssueProjectWorkspace(project.primaryWorkspace), + managedByPlugin: project.managedByPlugin ?? null, + taskCount: project.taskCount, + budget: project.budget, + archivedAt: project.archivedAt, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; + } + + function compactIssueRuntimeService(service: WorkspaceRuntimeService) { + return { + id: service.id, + companyId: service.companyId, + projectId: service.projectId, + projectWorkspaceId: service.projectWorkspaceId, + executionWorkspaceId: service.executionWorkspaceId, + issueId: service.issueId, + scopeType: service.scopeType, + scopeId: service.scopeId, + serviceName: service.serviceName, + status: service.status, + lifecycle: service.lifecycle, + reuseKey: service.reuseKey, + command: service.command, + cwd: service.cwd, + port: service.port, + url: service.url, + provider: service.provider, + providerRef: service.providerRef, + ownerAgentId: service.ownerAgentId, + startedByRunId: service.startedByRunId, + lastUsedAt: service.lastUsedAt, + startedAt: service.startedAt, + stoppedAt: service.stoppedAt, + healthStatus: service.healthStatus, + configIndex: service.configIndex ?? null, + }; + } + + function compactIssueExecutionWorkspace(workspace: ExecutionWorkspace | null) { + if (!workspace) return null; + return { + id: workspace.id, + companyId: workspace.companyId, + projectId: workspace.projectId, + projectWorkspaceId: workspace.projectWorkspaceId, + sourceIssueId: workspace.sourceIssueId, + mode: workspace.mode, + strategyType: workspace.strategyType, + name: workspace.name, + status: workspace.status, + cwd: workspace.cwd, + repoUrl: workspace.repoUrl, + baseRef: workspace.baseRef, + branchName: workspace.branchName, + providerType: workspace.providerType, + providerRef: workspace.providerRef, + derivedFromExecutionWorkspaceId: workspace.derivedFromExecutionWorkspaceId, + lastUsedAt: workspace.lastUsedAt, + openedAt: workspace.openedAt, + closedAt: workspace.closedAt, + cleanupEligibleAt: workspace.cleanupEligibleAt, + cleanupReason: workspace.cleanupReason, + config: workspace.config + ? { + environmentId: workspace.config.environmentId, + provisionCommand: workspace.config.provisionCommand, + teardownCommand: workspace.config.teardownCommand, + cleanupCommand: workspace.config.cleanupCommand, + workspaceRuntime: workspace.config.workspaceRuntime, + desiredState: workspace.config.desiredState, + serviceStates: workspace.config.serviceStates, + } + : null, + metadata: null, + runtimeServices: (workspace.runtimeServices ?? []) + .filter((service) => service.status === "starting" || service.status === "running") + .map(compactIssueRuntimeService), + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }; + } + // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes router.param("id", async (req, res, next, rawId) => { try { @@ -4568,10 +4701,10 @@ export function issueRoutes( relatedWork: referenceSummary, referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), ...documentPayload, - project: project ?? null, + project: compactIssueProject(project), goal: goal ?? null, mentionedProjects, - currentExecutionWorkspace, + currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace), workProducts, linkedCases, }); diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 2985b58d19..b7f80dca0d 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -401,7 +401,6 @@ export function activityService(db: Db) { continuationAttempt: heartbeatRuns.continuationAttempt, lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, nextAction: heartbeatRuns.nextAction, - contextSnapshot: heartbeatRuns.contextSnapshot, }) .from(heartbeatRuns) .innerJoin(