diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c157be51ae7..3149ee1cc5f 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,5 +1,9 @@ export const COMPANY_STATUSES = ["active", "paused", "archived"] as const; -export const ISSUE_SUMMARIES_MAX_IDS = 5; +/** + * IDs per `/issues/summaries` request. Large enough that a typical recent-task + * list is one request; callers split longer lists into parallel chunks. + */ +export const ISSUE_SUMMARIES_MAX_IDS = 50; /** Tasks per chat bootstrap request. The list preloads larger sets in chunks. */ export const ISSUE_CHAT_BOOTSTRAP_MAX_IDS = 10; export type CompanyStatus = (typeof COMPANY_STATUSES)[number]; diff --git a/server/src/__tests__/issue-summaries-routes.test.ts b/server/src/__tests__/issue-summaries-routes.test.ts index 032160b8f99..848671c3b42 100644 --- a/server/src/__tests__/issue-summaries-routes.test.ts +++ b/server/src/__tests__/issue-summaries-routes.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, expect, it, vi } from "vitest"; import { agents, chatConversations, chatEndpoints, heartbeatRuns, issueRelations, issues, toolApplications, toolConnections, } from "@paperclipai/db"; +import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; import { issueRoutes } from "../routes/issues.js"; import * as issueServices from "../services/issues.js"; import { errorHandler } from "../middleware/index.js"; @@ -59,12 +60,20 @@ describeEmbeddedPostgres("bounded issue summaries", () => { it.each(["", "?ids=", "?ids=nope", "?ids=,", "?ids[]=nope", "?ids[x]=nope", `?ids=${randomUUID()}&ids[]=nope`, `?ids=${randomUUID()}&ids[x]=nope`, - `?ids=${randomUUID()}&ids=${randomUUID()}`, `?ids=${Array(6).fill(randomUUID()).join(",")}`, + `?ids=${randomUUID()}&ids=${randomUUID()}`, `?ids=${Array.from({ length: ISSUE_SUMMARIES_MAX_IDS + 1 }, () => randomUUID()).join(",")}`, ])("rejects invalid query %s", async (query) => { const f = await fixture(); await request(app(f.actor)).get(url(f.companyId) + query).expect(400); }); + it("accepts exactly the maximum number of IDs", async () => { + const f = await fixture(); + const ids = [f.rows[0]!.id, f.rows[1]!.id, + ...Array.from({ length: ISSUE_SUMMARIES_MAX_IDS - 2 }, () => randomUUID())]; + const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ids.join(",") }).expect(200); + expect(res.body.map((row: { id: string }) => row.id)).toEqual([f.rows[0]!.id, f.rows[1]!.id]); + }); + it("omits hidden, private, foreign and missing IDs without revealing them", async () => { const f = await fixture(); const foreign = await fixture(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 88f2e545dc3..66796ee712c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -13,7 +13,7 @@ import { getHeartbeatRunReadDecisions, redactHeartbeatRunListRow } from "../serv import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js"; import { parseObject } from "../adapters/utils.js"; import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js"; -import { issueChatBootstrapQuerySchema, issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared"; +import { ISSUE_SUMMARIES_MAX_IDS, issueChatBootstrapQuerySchema, issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared"; import { readIssueChatBootstrapItems } from "../services/issue-chat-bootstrap.js"; import { issueRunPresenceService } from "../services/issue-run-presence.js"; import { activityService } from "../services/activity.js"; @@ -8572,13 +8572,13 @@ export function issueRoutes( } const parsed = issueSummariesQuerySchema.safeParse(req.query); if (!parsed.success) { - res.status(400).json({ error: "ids must be the only query parameter and contain one to five comma-separated UUIDs" }); + res.status(400).json({ error: `ids must be the only query parameter and contain one to ${ISSUE_SUMMARIES_MAX_IDS} comma-separated UUIDs` }); return; } const rows = await listIssueSummaries(db, companyId, parsed.data.ids, await issueReadSqlCondition(db, req.actor)); const visible = await filterIssuesForActor(req, rows); // Blocked rows carry glyph-only blocker state so every task view resolves - // the same glyph (covered → "in queue") as the task pane. At most five IDs. + // the same glyph (covered → "in queue") as the task pane. At most ISSUE_SUMMARIES_MAX_IDS IDs. const blockerAttention = await listIssueStatusBlockerAttention(db, companyId, visible, async (ids) => new Set( (await filterIssuesForActor(req, await listIssueSummaries(db, companyId, ids, await issueReadSqlCondition(db, req.actor)))) .map((row) => row.id), diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 05cf6004e55..2f438032a26 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4367,10 +4367,10 @@ registry.registerPath({ path: "/api/companies/{companyId}/issues/summaries", tags: ["issues"], summary: "Get authorized recent-task summaries", - description: "Accepts only the ids query parameter: one to five comma-separated UUIDs, bounded before deduplication. IDs are trimmed and normalized to lowercase. Repeated, array and nested query syntax is rejected, including mixed forms. Returns accessible, visible tasks in request order; unavailable IDs are omitted. This private, no-store read excludes full task and workspace enrichment.", + description: "Accepts only the ids query parameter: one to fifty comma-separated UUIDs, bounded before deduplication. IDs are trimmed and normalized to lowercase. Repeated, array and nested query syntax is rejected, including mixed forms. Returns accessible, visible tasks in request order; unavailable IDs are omitted. This private, no-store read excludes full task and workspace enrichment.", request: { params: z.object({ companyId: z.string() }), - query: z.object({ ids: z.string().min(1).describe("One to five comma-separated task UUIDs; repeated query parameters are rejected") }), + query: z.object({ ids: z.string().min(1).describe("One to fifty comma-separated task UUIDs; repeated query parameters are rejected") }), }, responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, }); diff --git a/tests/perf/collections/recent-task-summaries.spec.ts b/tests/perf/collections/recent-task-summaries.spec.ts new file mode 100644 index 00000000000..e9f938fa79d --- /dev/null +++ b/tests/perf/collections/recent-task-summaries.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; +import { collectionFixture, type CollectionFixture } from "./fixture"; + +let fixture: CollectionFixture; +test.beforeAll(async ({ request }) => { + fixture = await collectionFixture(request); +}); + +// A 12-task history used to cost three serial five-ID summary requests on +// every page load. It must be one request. +test("Mine refreshes a recent-task history with one summaries request", async ({ page, request }) => { + const session = await (await request.get("/api/auth/get-session")).json().catch(() => null); + const userId: string | null = session?.user?.id ?? session?.session?.userId ?? null; + const companyId = fixture.company.id; + const taskIds = fixture.taskIds.slice(0, 12); + await page.addInitScript(({ companyId, userId, taskIds }) => { + const entries = taskIds.map((id, index) => ({ + id, companyId, title: `Recent ${index}`, identifier: null, status: "backlog", recordedAt: Date.now() - index * 1000, + })); + for (const owner of new Set([userId ?? "__local_board__", "__local_board__"])) { + localStorage.setItem(`paperclip.recentTasks.v2:${companyId}:${owner}`, JSON.stringify(entries)); + } + }, { companyId, userId, taskIds }); + const summaries: URL[] = []; + page.on("request", item => { + const url = new URL(item.url()); + if (url.pathname === `/api/companies/${companyId}/issues/summaries`) summaries.push(url); + }); + const response = page.waitForResponse(item => new URL(item.url()).pathname === `/api/companies/${companyId}/issues/summaries`); + await page.goto(`/${fixture.company.issuePrefix}/issues?view=mine`); + // The server must accept the whole batch, not just receive it. + const first = await response; + expect(first.status()).toBe(200); + const rows = await first.json() as Array<{ id: string }>; + expect(rows.map(row => row.id).sort()).toEqual([...taskIds].sort()); + // Allow any follow-up chunk to start before counting. + await page.waitForTimeout(1_500); + // The live-updates socket may refresh the batch once after it opens; that + // repeats the same request. Chunked requests would each carry fewer IDs. + for (const url of summaries) expect(url.searchParams.get("ids")!.split(",")).toHaveLength(12); + expect(summaries.length).toBeLessThanOrEqual(2); +}); diff --git a/ui/src/api/issue-summaries.test.ts b/ui/src/api/issue-summaries.test.ts index 4e4f95f84db..b304d5740ba 100644 --- a/ui/src/api/issue-summaries.test.ts +++ b/ui/src/api/issue-summaries.test.ts @@ -1,4 +1,5 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; import { issuesApi } from "./issues"; const first = "11111111-1111-4111-8111-111111111111"; @@ -22,7 +23,7 @@ describe("issue summary HTTP requests", () => { it("does not send empty or oversized batches", async () => { expect(await issuesApi.summaries("company", [])).toEqual([]); - await expect(issuesApi.summaries("company", Array(6).fill(first))).rejects.toThrow("Too many"); + await expect(issuesApi.summaries("company", Array(ISSUE_SUMMARIES_MAX_IDS + 1).fill(first))).rejects.toThrow("Too many"); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/ui/src/components/task-chat/TaskChatBlockerLinks.test.tsx b/ui/src/components/task-chat/TaskChatBlockerLinks.test.tsx index e1dc2eee54b..389c5538a7d 100644 --- a/ui/src/components/task-chat/TaskChatBlockerLinks.test.tsx +++ b/ui/src/components/task-chat/TaskChatBlockerLinks.test.tsx @@ -50,7 +50,7 @@ function relation(id: string, identifier: string, status: IssueRelationIssueSumm // The onboarding walk: BRA-1 waits on BRA-2 (running), todo tasks and // blocked tasks that are "covered" in the task list. Six steps plus a live -// terminal blocker exceed one summaries request (at most five IDs). +// terminal blocker; every summaries request stays within the endpoint limit. const terminal = relation("bra-9", "BRA-9", "in_progress"); const blockers = [ { ...relation("bra-2", "BRA-2", "in_progress"), terminalBlockers: [terminal] }, diff --git a/ui/src/hooks/useRecentTasks.test.tsx b/ui/src/hooks/useRecentTasks.test.tsx index 499fb1c034c..064c0e53e6b 100644 --- a/ui/src/hooks/useRecentTasks.test.tsx +++ b/ui/src/hooks/useRecentTasks.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; import { ApiError } from "@/api/client"; import { queryKeys } from "@/lib/queryKeys"; import { @@ -12,7 +13,7 @@ import { recordRecentTask, RECENT_TASKS_UPDATED_EVENT, } from "@/lib/recent-tasks"; -import { useRecentTasks } from "./useRecentTasks"; +import { RECENT_TASK_SUMMARY_CONCURRENCY, useRecentTasks } from "./useRecentTasks"; const { getIssue, summaries } = vi.hoisted(() => ({ getIssue: vi.fn(), summaries: vi.fn() })); vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue, summaries } })); @@ -182,23 +183,62 @@ describe("recent task synchronization", () => { expect(summaries).not.toHaveBeenCalled(); }); - it.each([false, true])("refreshes long histories in bounded requests without pruning on a partial failure (%s)", async fails => { - const tasks = Array.from({ length: 13 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${i}` })); + it("fetches a 25-task history in one request", async () => { + const tasks = Array.from({ length: 25 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(2, "0")}` })); tasks.forEach(issue => recordRecentTask(issue, "user-1")); + summaries.mockResolvedValue(tasks); + await act(async () => root.render()); + await flush(); + expect(summaries).toHaveBeenCalledTimes(1); + expect(summaries.mock.calls[0][1]).toHaveLength(25); + }); + + it.each([false, true])("refreshes long histories in parallel bounded requests without pruning on a partial failure (%s)", async fails => { + // Three chunks, all in flight together within the concurrency limit. + const count = ISSUE_SUMMARIES_MAX_IDS * 2 + 3; + const tasks = Array.from({ length: count }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(3, "0")}` })); + tasks.forEach(issue => recordRecentTask(issue, "user-1")); + const pending: Array<() => void> = []; summaries.mockImplementation(async (_company, ids: string[]) => { - expect(ids.length).toBeLessThanOrEqual(5); - if (fails && summaries.mock.calls.length === 2) throw new ApiError("Unavailable", 500, null); - return tasks.filter(issue => ids.includes(issue.id)).map(issue => ({ ...issue, title: `Refreshed ${issue.title}`, updatedAt: new Date(100) })); + expect(ids.length).toBeLessThanOrEqual(ISSUE_SUMMARIES_MAX_IDS); + const call = summaries.mock.calls.length; + // Each chunk waits until the test releases it, so all three must be in flight together. + await new Promise(resolve => pending.push(resolve)); + if (fails && call === 2) throw new ApiError("Unavailable", 500, null); + return tasks.filter(issue => ids.includes(issue.id)).map(issue => ({ ...issue, title: `Refreshed ${issue.title}`, updatedAt: new Date(10_000) })); }); await act(async () => root.render()); await flush(); - expect(summaries).toHaveBeenCalledTimes(fails ? 2 : 3); + expect(summaries).toHaveBeenCalledTimes(3); + expect(pending).toHaveLength(3); + pending.forEach(resolve => resolve()); + await flush(); const entries = readRecentTasks(storageKey, "company-1"); - expect(entries).toHaveLength(13); + expect(entries).toHaveLength(count); expect(entries.every(entry => entry.title.startsWith("Refreshed"))).toBe(!fails); expect(getIssue).not.toHaveBeenCalled(); }); + it("limits how many summary chunks are in flight at once", async () => { + const count = ISSUE_SUMMARIES_MAX_IDS * 5; + const tasks = Array.from({ length: count }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(3, "0")}` })); + tasks.forEach(issue => recordRecentTask(issue, "user-1")); + const pending: Array<() => void> = []; + summaries.mockImplementation(async (_company, ids: string[]) => { + await new Promise(resolve => pending.push(resolve)); + return tasks.filter(issue => ids.includes(issue.id)); + }); + await act(async () => root.render()); + await flush(); + expect(summaries).toHaveBeenCalledTimes(RECENT_TASK_SUMMARY_CONCURRENCY); + while (pending.length) { + pending.splice(0).forEach(resolve => resolve()); + await flush(); + } + expect(summaries).toHaveBeenCalledTimes(5); + expect(readRecentTasks(storageKey, "company-1")).toHaveLength(count); + }); + it("prunes successful omissions without resurrecting cached full details", async () => { const missing = task(10, "Removed"); recordRecentTask(missing, "user-1"); diff --git a/ui/src/hooks/useRecentTasks.ts b/ui/src/hooks/useRecentTasks.ts index 471571a9be4..eadab2beb51 100644 --- a/ui/src/hooks/useRecentTasks.ts +++ b/ui/src/hooks/useRecentTasks.ts @@ -20,6 +20,8 @@ type RecentTasksUpdatedDetail = { }; const RECENT_TASK_ORDER_DEBOUNCE_MS = 1_000; +/** Summary chunks in flight at once for a history longer than one request. */ +export const RECENT_TASK_SUMMARY_CONCURRENCY = 3; export function useRecentTasks({ companyId, @@ -82,11 +84,22 @@ export function useRecentTasks({ const summaryQuery = useQuery({ queryKey: summaryKey, queryFn: async ({ signal }) => { - const summaries: IssueSummary[] = []; + // One request covers a typical list. Longer lists fetch their chunks a + // few at a time rather than one after another or all at once. + const chunks: string[][] = []; for (let offset = 0; offset < queryIds.length; offset += ISSUE_SUMMARIES_MAX_IDS) { - summaries.push(...await issuesApi.summaries(companyId!, queryIds.slice(offset, offset + ISSUE_SUMMARIES_MAX_IDS), { signal })); + chunks.push(queryIds.slice(offset, offset + ISSUE_SUMMARIES_MAX_IDS)); } - return summaries; + const results: IssueSummary[][] = new Array(chunks.length); + let next = 0; + const worker = async () => { + while (next < chunks.length) { + const index = next++; + results[index] = await issuesApi.summaries(companyId!, chunks[index]!, { signal }); + } + }; + await Promise.all(Array.from({ length: Math.min(RECENT_TASK_SUMMARY_CONCURRENCY, chunks.length) }, worker)); + return results.flat(); }, enabled: Boolean(companyId && queryIds.length), staleTime: 30_000,