diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0cc99826432..fab8c1ed7a1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -224,6 +224,7 @@ export type { ProjectGoalRef, ProjectWorkspace, ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6c38141de9c..62ca4dde0ac 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -63,6 +63,7 @@ export type { AssetImage } from "./asset.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; export type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 9e287200fa8..4e6b01cdace 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -161,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings { workspaceRuntime?: Record | null; } +export interface ExecutionWorkspaceSummary { + id: string; + name: string; + mode: Exclude | "adapter_managed" | "cloud_sandbox"; + projectWorkspaceId: string | null; +} + export interface ExecutionWorkspace { id: string; companyId: string; diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts new file mode 100644 index 00000000000..a5295fe09ff --- /dev/null +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -0,0 +1,90 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + list: vi.fn(), + listSummaries: vi.fn(), + getById: vi.fn(), + getCloseReadiness: vi.fn(), + update: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({ + listForExecutionWorkspace: vi.fn(), + createRecorder: vi.fn(), +})); + +function registerServiceMocks() { + vi.doMock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: vi.fn(async () => undefined), + workspaceOperationService: () => mockWorkspaceOperationService, + })); +} + +async function createApp() { + const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/execution-workspaces.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", executionWorkspaceRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("execution workspace routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/execution-workspaces.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerServiceMocks(); + vi.resetAllMocks(); + mockExecutionWorkspaceService.list.mockResolvedValue([]); + mockExecutionWorkspaceService.listSummaries.mockResolvedValue([ + { + id: "workspace-1", + name: "Alpha", + mode: "isolated_workspace", + projectWorkspaceId: null, + }, + ]); + }); + + it("uses summary mode for lightweight workspace lookups", async () => { + const res = await request(await createApp()) + .get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { + id: "workspace-1", + name: "Alpha", + mode: "isolated_workspace", + projectWorkspaceId: null, + }, + ]); + expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", { + projectId: undefined, + projectWorkspaceId: undefined, + issueId: undefined, + status: undefined, + reuseEligible: true, + }); + expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index faabd53c50b..20e80c53e43 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -37,13 +37,16 @@ export function executionWorkspaceRoutes(db: Db) { router.get("/companies/:companyId/execution-workspaces", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const workspaces = await svc.list(companyId, { + const filters = { projectId: req.query.projectId as string | undefined, projectWorkspaceId: req.query.projectWorkspaceId as string | undefined, issueId: req.query.issueId as string | undefined, status: req.query.status as string | undefined, reuseEligible: req.query.reuseEligible === "true", - }); + }; + const workspaces = req.query.summary === "true" + ? await svc.listSummaries(companyId, filters) + : await svc.list(companyId, filters); res.json(workspaces); }); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index af1c3ee81a5..1832a1a1b5e 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -7,6 +7,7 @@ import type { Db } from "@paperclipai/db"; import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseGitReadiness, ExecutionWorkspaceCloseReadiness, @@ -336,6 +337,15 @@ function toExecutionWorkspace( }; } +function toExecutionWorkspaceSummary(row: Pick): ExecutionWorkspaceSummary { + return { + id: row.id, + name: row.name, + mode: row.mode as ExecutionWorkspaceSummary["mode"], + projectWorkspaceId: row.projectWorkspaceId ?? null, + }; +} + function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) { if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false; return !readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null)?.workspaceRuntime; @@ -372,6 +382,33 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace( } export function executionWorkspaceService(db: Db) { + function buildListConditions( + companyId: string, + filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }, + ) { + const conditions = [eq(executionWorkspaces.companyId, companyId)]; + if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); + if (filters?.projectWorkspaceId) { + conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); + } + if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); + if (filters?.status) { + const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); + if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); + else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); + } + if (filters?.reuseEligible) { + conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); + } + return conditions; + } + return { list: async (companyId: string, filters?: { projectId?: string; @@ -380,21 +417,7 @@ export function executionWorkspaceService(db: Db) { status?: string; reuseEligible?: boolean; }) => { - const conditions = [eq(executionWorkspaces.companyId, companyId)]; - if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); - if (filters?.projectWorkspaceId) { - conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); - } - if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); - if (filters?.status) { - const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); - if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); - else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); - } - if (filters?.reuseEligible) { - conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); - } - + const conditions = buildListConditions(companyId, filters); const rows = await db .select() .from(executionWorkspaces) @@ -409,6 +432,27 @@ export function executionWorkspaceService(db: Db) { ); }, + listSummaries: async (companyId: string, filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }) => { + const conditions = buildListConditions(companyId, filters); + const rows = await db + .select({ + id: executionWorkspaces.id, + name: executionWorkspaces.name, + mode: executionWorkspaces.mode, + projectWorkspaceId: executionWorkspaces.projectWorkspaceId, + }) + .from(executionWorkspaces) + .where(and(...conditions)) + .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); + return rows.map((row) => toExecutionWorkspaceSummary(row)); + }, + getById: async (id: string) => { const row = await db .select() diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts new file mode 100644 index 00000000000..b18bd6501f4 --- /dev/null +++ b/ui/src/api/execution-workspaces.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { executionWorkspacesApi } from "./execution-workspaces"; + +describe("executionWorkspacesApi.listSummaries", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockResolvedValue([]); + }); + + it("requests the lightweight summary payload", async () => { + await executionWorkspacesApi.listSummaries("company-1", { + projectId: "project-1", + reuseEligible: true, + }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true", + ); + }); +}); diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 2c10a4dc44a..cd5b7341752 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -1,5 +1,6 @@ import type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceCloseReadiness, WorkspaceOperation, WorkspaceRuntimeControlTarget, @@ -8,6 +9,28 @@ import { api } from "./client"; import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; export const executionWorkspacesApi = { + listSummaries: ( + companyId: string, + filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }, + ) => { + const params = new URLSearchParams(); + if (filters?.projectId) params.set("projectId", filters.projectId); + if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId); + if (filters?.issueId) params.set("issueId", filters.issueId); + if (filters?.status) params.set("status", filters.status); + if (filters?.reuseEligible) params.set("reuseEligible", "true"); + params.set("summary", "true"); + const qs = params.toString(); + return api.get( + `/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`, + ); + }, list: ( companyId: string, filters?: { diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 938aa383f4d..c3073201668 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -28,6 +28,7 @@ const mockAuthApi = vi.hoisted(() => ({ const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn(), + listSummaries: vi.fn(), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ @@ -183,11 +184,13 @@ describe("IssuesList", () => { mockIssuesApi.listLabels.mockReset(); mockAuthApi.getSession.mockReset(); mockExecutionWorkspacesApi.list.mockReset(); + mockExecutionWorkspacesApi.listSummaries.mockReset(); mockInstanceSettingsApi.getExperimental.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: null, session: null }); mockExecutionWorkspacesApi.list.mockResolvedValue([]); + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); localStorage.clear(); }); @@ -460,7 +463,7 @@ describe("IssuesList", () => { it("filters the list to a single workspace when a workspace name is clicked", async () => { localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "workspace"])); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); - mockExecutionWorkspacesApi.list.mockResolvedValue([ + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ { id: "workspace-alpha", name: "Alpha", @@ -661,4 +664,29 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("uses workspace summaries instead of the full workspace list on the issues page", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1"); + expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled(); + }); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 62f8fa2a9cc..879dcc8a00c 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -350,9 +350,9 @@ export function IssuesList({ }); const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId - ? queryKeys.executionWorkspaces.list(selectedCompanyId) + ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) : ["execution-workspaces", "__disabled__"], - queryFn: () => executionWorkspacesApi.list(selectedCompanyId!), + queryFn: () => executionWorkspacesApi.listSummaries(selectedCompanyId!), enabled: !!selectedCompanyId && isolatedWorkspacesEnabled, }); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index f7c47b45c75..6c67cbd0a1a 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -65,6 +65,8 @@ export const queryKeys = { executionWorkspaces: { list: (companyId: string, filters?: Record) => ["execution-workspaces", companyId, filters ?? {}] as const, + summaryList: (companyId: string, filters?: Record) => + ["execution-workspaces", companyId, "summary", filters ?? {}] as const, detail: (id: string) => ["execution-workspaces", "detail", id] as const, closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const, workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,