mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Optimize issues workspace lookups
(cherry picked from commit 493ef18a2f68ee5372a2da585a2f6b709f5cf377)
This commit is contained in:
1 parent
801a91e039
commit
fd22d10db3
11 files changed
+248
-20
No files matched your search
@@ -224,6 +224,7 @@ export type {
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
ExecutionWorkspaceConfig,
|
||||
ExecutionWorkspaceCloseAction,
|
||||
ExecutionWorkspaceCloseActionKind,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -161,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings {
|
||||
workspaceRuntime?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing" | "agent_default"> | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspace {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
||||
@@ -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<typeof import("../routes/execution-workspaces.js")>("../routes/execution-workspaces.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ExecutionWorkspaceRow, "id" | "name" | "mode" | "projectWorkspaceId">): 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<string, unknown> | 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()
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<ExecutionWorkspaceSummary[]>(
|
||||
`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
list: (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
|
||||
@@ -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(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1");
|
||||
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ export const queryKeys = {
|
||||
executionWorkspaces: {
|
||||
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||
["execution-workspaces", companyId, filters ?? {}] as const,
|
||||
summaryList: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||
["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,
|
||||
|
||||
Reference in new issue
Block a user