Speed up issue detail payloads (#9125)

## 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
This commit is contained in:
Dotta 2026-07-07 06:28:54 -05:00 committed by GitHub
parent f42a66bf04
commit 88ce6d3575
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 278 additions and 3 deletions

View File

@ -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 () => {

View File

@ -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",

View File

@ -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<ReturnType<typeof resolveIssueProjectAndGoal>>["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,
});

View File

@ -401,7 +401,6 @@ export function activityService(db: Db) {
continuationAttempt: heartbeatRuns.continuationAttempt,
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
nextAction: heartbeatRuns.nextAction,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.innerJoin(