diff --git a/server/src/__tests__/documents.test.ts b/server/src/__tests__/documents.test.ts index c36b7265acb..c80d650e919 100644 --- a/server/src/__tests__/documents.test.ts +++ b/server/src/__tests__/documents.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; -import { extractLegacyPlanBody } from "../services/documents.js"; +import { describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { documentService, extractLegacyPlanBody } from "../services/documents.js"; describe("extractLegacyPlanBody", () => { it("returns null when no plan block exists", () => { @@ -27,3 +28,43 @@ intro expect(extractLegacyPlanBody(" ")).toBeNull(); }); }); + + +describe("document creation conflicts", () => { + function serviceWithTransactionError(error: Error) { + const db = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve([{ id: "issue-1", companyId: "company-1" }]), + }), + }), + transaction: vi.fn().mockRejectedValue(error), + }; + return documentService(db as unknown as Db); + } + + const input = { issueId: "issue-1", key: "plan", format: "markdown", body: "# Plan" }; + + it("returns a retryable conflict for a Drizzle-wrapped concurrent insert", async () => { + const error = new Error("Failed query: insert into issue_documents", { + cause: Object.assign(new Error("duplicate key"), { + code: "23505", + constraint_name: "issue_documents_company_issue_key_uq", + }), + }); + await expect(serviceWithTransactionError(error).upsertIssueDocument(input)).rejects.toMatchObject({ + status: 409, + message: "Document key already exists on this issue", + details: { key: "plan" }, + }); + }); + + it.each([ + { code: "23503", constraint_name: "issue_documents_issue_id_issues_id_fk" }, + { code: "23505", constraint_name: "document_revisions_document_revision_uq" }, + { code: "23505", constraint_name: "issue_documents_document_uq" }, + ])("preserves unrelated database failures ($constraint_name)", async (cause) => { + const error = new Error("Failed query", { cause }); + await expect(serviceWithTransactionError(error).upsertIssueDocument(input)).rejects.toBe(error); + }); +}); diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 9517456b53e..1ee5dcc2f27 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db"; import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db"; import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; +import { isUniqueViolation } from "../db-errors.js"; import { insertRowsInChunks } from "./batch-insert.js"; import type { ImportIssueDocumentRow } from "./import-write-types.js"; @@ -16,10 +17,6 @@ function normalizeDocumentKey(key: string) { return parsed.data; } -function isUniqueViolation(error: unknown): boolean { - return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505"; -} - function nextAvailableDocumentKey(sourceKey: string, existingKeys: string[]) { const usedKeys = new Set(existingKeys); for (let index = 2; index < 1000; index += 1) { @@ -502,7 +499,7 @@ export function documentService(db: Db) { }; }); } catch (error) { - if (isUniqueViolation(error)) { + if (isUniqueViolation(error, "issue_documents_company_issue_key_uq")) { if (input.lockedDocumentStrategy === "create_new_document" && attempt < maxAttempts - 1) { continue; } diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index f14a659fa42..2cd0a36adaf 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -813,7 +813,10 @@ describe("Inbox toolbar", () => { , ); }); - await vi.waitFor(() => expect(container.textContent).toContain("Workspace-aware task")); + // Workspace metadata resolves independently of the task list. + await vi.waitFor(() => { + expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Workspace Aurora"); + }); const taskRow = container.querySelector('[data-slot="task-row"]'); const identifier = taskRow?.querySelector('[data-slot="task-row-identifier"]'); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index c5cf9dec795..3e5a8f8e217 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -41,6 +41,7 @@ import { } from "../lib/issueDetailBreadcrumb"; import { getRecentTasksStorageKey, readRecentTasks } from "../lib/recent-tasks"; import { ApiError } from "../api/client"; +import type { issuesApi } from "../api/issues"; const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn(), @@ -5290,6 +5291,93 @@ describe("IssueDetail", () => { localStorage.removeItem("paperclip:issue-comment-draft:issue-1"); }); + describe.each([false, true])("composer tree control (mobile=%s)", (isMobile) => { + const treeControlKey = ["issues", "tree-control-state", "PAP-1"]; + const composerProps = () => mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { + composerDisabledReason: string | null; + composerPause: TaskComposerPause | null; + onAdd: (body: string) => Promise; + }; + + async function renderWithPendingTreeControl() { + mockSidebarState.isMobile = isMobile; + mockIssuesApi.get.mockResolvedValue(createIssue()); + let resolve!: (value: Awaited>) => void; + let reject!: (reason: Error) => void; + const promise = new Promise>>((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + const response = { promise, resolve, reject }; + mockIssuesApi.getTreeControlState.mockReturnValue(response.promise); + await act(async () => { + root.render( + + + , + ); + }); + await waitForAssertion(() => { + expect(mockIssuesApi.getTreeControlState).toHaveBeenCalledWith("PAP-1"); + expect(mockIssueChatThreadRender).toHaveBeenCalled(); + expect(queryClient.getQueryState(treeControlKey)?.status).toBe("pending"); + expect(queryClient.getQueryState(treeControlKey)?.fetchStatus).toBe("fetching"); + }); + return response; + } + + it("allows a message while tree control is pending and remains enabled after success", async () => { + mockIssuesApi.addComment.mockClear().mockResolvedValue(createIssueComment({ body: "Keep working" })); + const response = await renderWithPendingTreeControl(); + expect(composerProps().composerDisabledReason).toBeNull(); + expect(composerProps().composerPause).toBeNull(); + await act(async () => { + await composerProps().onAdd("Keep working"); + }); + expect(mockIssuesApi.addComment).toHaveBeenCalledWith( + "PAP-1", "Keep working", undefined, undefined, undefined, expect.any(String), + ); + expect(queryClient.getQueryState(treeControlKey)?.status).toBe("pending"); + + response.resolve({ activePauseHold: null }); + await waitForAssertion(() => { + expect(queryClient.getQueryState(treeControlKey)?.status).toBe("success"); + expect(composerProps().composerDisabledReason).toBeNull(); + expect(composerProps().composerPause).toBeNull(); + }); + }); + + it("blocks the composer when the pending tree control request fails", async () => { + const response = await renderWithPendingTreeControl(); + response.reject(new Error("tree control unavailable")); + await waitForAssertion(() => { + expect(queryClient.getQueryState(treeControlKey)?.status).toBe("error"); + expect(composerProps().composerDisabledReason).toBe( + "Couldn’t check whether this task is paused. Refresh to try again.", + ); + }); + }); + + it.each([true, false])("applies a late pause response (root=%s)", async (isRoot) => { + const response = await renderWithPendingTreeControl(); + response.resolve({ + activePauseHold: { + holdId: "hold-1", + rootIssueId: isRoot ? "issue-1" : "parent-1", + issueId: "issue-1", + isRoot, + mode: "pause", + reason: null, + releasePolicy: { strategy: "manual" }, + }, + }); + await waitForAssertion(() => { + expect(composerProps().composerPause?.scope).toBe(isRoot ? "leaf" : "subtree"); + expect(container.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull(); + }); + }); + }); + it("renders a quiet task pause notice and defaults leaf resume to wake the assignee", async () => { const activeHold = createPauseHold(); const releasedHold = createPauseHold({ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 0cc45cb507e..15d14bef1fd 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3440,7 +3440,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS staleTime: 0, retry: false, }); - const { data: treeControlState, isPending: treeControlStatePending, error: treeControlStateError } = useQuery({ + const { data: treeControlState, error: treeControlStateError } = useQuery({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"], queryFn: () => issuesApi.getTreeControlState(issueId!), enabled: !!issueId, @@ -7834,7 +7834,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS } : undefined, resumeHref: !activePauseHold.isRoot ? createIssueDetailPath(activePauseHoldRoot?.identifier ?? activePauseHold.rootIssueId) : undefined, } : null} - composerDisabledReason={issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : issueId && treeControlStatePending ? "Checking task status…" : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null} + composerDisabledReason={issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null} composerHint={composerHint} queuedCommentReason={queuedCommentReason} onVote={handleCommentVote}