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(