diff --git a/src/app/api/sources/[sourceId]/route.test.ts b/src/app/api/sources/[sourceId]/route.test.ts index b82d620..6f82574 100644 --- a/src/app/api/sources/[sourceId]/route.test.ts +++ b/src/app/api/sources/[sourceId]/route.test.ts @@ -14,7 +14,9 @@ const mocks = vi.hoisted(() => { hideDemoSource: vi.fn(), makeKnowhereClient: vi.fn(), requireUser: vi.fn(), + retrySourceToKnowhere: vi.fn(), softDeleteSource: vi.fn(), + startBackgroundReconciliation: vi.fn(), }; }); @@ -46,10 +48,15 @@ vi.mock("@/integrations/knowhere", () => ({ makeKnowhereClient: mocks.makeKnowhereClient, })); +vi.mock("@/domains/sources/background-reconcile", () => ({ + startBackgroundReconciliation: mocks.startBackgroundReconciliation, +})); + vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, hideDemoSource: mocks.hideDemoSource, + retrySourceToKnowhere: mocks.retrySourceToKnowhere, softDelete: mocks.softDeleteSource, }, })); @@ -255,4 +262,124 @@ describe("PATCH /api/sources/[sourceId]", () => { expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled(); expect(mocks.archive).not.toHaveBeenCalled(); }); + + it("retries a failed source and starts background reconciliation", async () => { + mocks.requireUser.mockResolvedValue({ id: "user_1" }); + mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); + mocks.findSourceInWorkspace.mockResolvedValue({ + id: "source_1", + workspaceId: "workspace_1", + title: "lecture.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + status: "failed", + failureReason: "Knowhere upload failed.", + knowhereJobId: null, + knowhereDocumentId: null, + stagedBlobPathname: null, + stagedBlobUrl: null, + originalBlobPathname: "source-uploads/upload_1/document.pdf", + originalBlobUrl: + "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + demoKey: null, + createdAt: new Date("2026-05-10T00:00:00Z"), + updatedAt: new Date("2026-05-10T00:00:00Z"), + deletedAt: null, + }); + mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123"); + const knowhereClient = { + jobs: { + create: vi.fn(), + get: vi.fn(), + upload: vi.fn(), + }, + documents: { + archive: mocks.archive, + }, + }; + mocks.makeKnowhereClient.mockReturnValue(knowhereClient); + mocks.retrySourceToKnowhere.mockResolvedValue({ + id: "source_1", + workspaceId: "workspace_1", + title: "lecture.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + status: "parsing", + failureReason: null, + knowhereJobId: "job_retry", + knowhereDocumentId: null, + stagedBlobPathname: null, + stagedBlobUrl: null, + originalBlobPathname: "source-uploads/upload_1/document.pdf", + originalBlobUrl: + "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + demoKey: null, + createdAt: new Date("2026-05-10T00:00:00Z"), + updatedAt: new Date("2026-05-10T00:00:00Z"), + deletedAt: null, + }); + mocks.startBackgroundReconciliation.mockResolvedValue(undefined); + + const response = await PATCH( + new NextRequest("http://localhost:3001/api/sources/source_1", { + method: "PATCH", + body: JSON.stringify({ retry: true }), + }), + { params: Promise.resolve({ sourceId: "source_1" }) }, + ); + + await expect(response.json()).resolves.toEqual({ + source: { + id: "source_1", + kind: "workspace", + title: "lecture.pdf", + mimeType: "application/pdf", + status: "parsing", + documentId: undefined, + originalFile: { + url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + }, + }, + }); + expect(response.status).toBe(200); + expect(mocks.retrySourceToKnowhere).toHaveBeenCalledWith( + { id: "workspace_1" }, + expect.objectContaining({ id: "source_1", status: "failed" }), + knowhereClient, + ); + expect(mocks.startBackgroundReconciliation).toHaveBeenCalledWith( + "workspace_1", + "source_1", + "jwt_123", + ); + }); + + it("rejects retry requests for failed rows without a saved original Blob", async () => { + mocks.requireUser.mockResolvedValue({ id: "user_1" }); + mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); + mocks.findSourceInWorkspace.mockResolvedValue({ + id: "source_1", + status: "failed", + originalBlobPathname: null, + originalBlobUrl: null, + }); + + const response = await PATCH( + new NextRequest("http://localhost:3001/api/sources/source_1", { + method: "PATCH", + body: JSON.stringify({ retry: true }), + }), + { params: Promise.resolve({ sourceId: "source_1" }) }, + ); + + await expect(response.json()).resolves.toEqual({ + message: + "This source cannot be retried because its original file is unavailable.", + }); + expect(response.status).toBe(409); + expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled(); + expect(mocks.retrySourceToKnowhere).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/sources/[sourceId]/route.ts b/src/app/api/sources/[sourceId]/route.ts index 9d30567..9d05d81 100644 --- a/src/app/api/sources/[sourceId]/route.ts +++ b/src/app/api/sources/[sourceId]/route.ts @@ -19,18 +19,19 @@ export async function PATCH( ): Promise { const { sourceId } = await context.params const routeContext = await nextRouteContext.read() - const archiveRequest = await sourceRouteRequest.readArchiveSource({ + const mutationRequest = await sourceRouteRequest.readSourceMutation({ cookieHeader: routeContext.cookieHeader, request, sourceId, }) - if (!archiveRequest.ok) { - return nextRouteResponse.toNextResponse(archiveRequest.result) + if (!mutationRequest.ok) { + return nextRouteResponse.toNextResponse(mutationRequest.result) } - const result = await sourceRouteService.archiveSource({ - ...archiveRequest.input, - }) + const result = + mutationRequest.mutation.kind === "archive" + ? await sourceRouteService.archiveSource(mutationRequest.mutation.input) + : await sourceRouteService.retrySource(mutationRequest.mutation.input) return nextRouteResponse.toNextResponse(result) } diff --git a/src/components/source-row.test.ts b/src/components/source-row.test.ts index 9faa567..26e413f 100644 --- a/src/components/source-row.test.ts +++ b/src/components/source-row.test.ts @@ -75,6 +75,99 @@ describe("SourceRow", () => { .toBeTruthy(); }); + it("shows failed source retry with a brief error message", () => { + const onRetryClick = vi.fn(); + const onSelect = vi.fn(); + + render( + React.createElement(SourceRow, { + isArchiving: false, + isSelected: false, + onRetryClick, + onSelect, + source: { + id: "source_1", + mimeType: "application/pdf", + title: "lecture.pdf", + status: "failed", + failureMessage: + "Too many concurrent requests (2/2 active). Please retry after 30 seconds.", + originalFile: { + url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + mimeType: "application/pdf", + }, + }, + }), + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Retry lecture.pdf processing", + }), + ); + + expect( + screen.getByText( + "Too many concurrent requests (2/2 active). Please retry after 30 seconds.", + ), + ).toBeTruthy(); + expect(onRetryClick).toHaveBeenCalledWith("source_1"); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("shows failed source retry loading locally", () => { + render( + React.createElement(SourceRow, { + isArchiving: false, + isRetrying: true, + isSelected: false, + onRetryClick: vi.fn(), + onSelect: vi.fn(), + source: { + id: "source_1", + mimeType: "application/pdf", + title: "lecture.pdf", + status: "failed", + originalFile: { + url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf", + mimeType: "application/pdf", + }, + }, + }), + ); + + const retryButton = screen.getByRole("button", { + name: "Retry lecture.pdf processing", + }); + + expect((retryButton as HTMLButtonElement).disabled).toBe(true); + expect(within(retryButton).getByRole("status", { name: "Loading" })) + .toBeTruthy(); + }); + + it("hides retry when a failed source has no saved original file", () => { + render( + React.createElement(SourceRow, { + isArchiving: false, + isSelected: false, + onRetryClick: vi.fn(), + onSelect: vi.fn(), + source: { + id: "source_1", + mimeType: "application/pdf", + title: "legacy.pdf", + status: "failed", + }, + }), + ); + + expect( + screen.queryByRole("button", { + name: "Retry legacy.pdf processing", + }), + ).toBeNull(); + }); + it("links ready sources to the document chunk tree route", () => { const onSelect = vi.fn(); diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx index 77cf9d7..abfe8cb 100644 --- a/src/components/source-row.tsx +++ b/src/components/source-row.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from "react"; import Link from "next/link"; -import { FileText, ListTree, Plus, Trash2 } from "lucide-react"; +import { FileText, ListTree, Plus, RotateCcw, Trash2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { Spinner } from "@/components/ui/spinner"; @@ -13,9 +13,11 @@ export type SourceRowProps = { readonly isArchiving: boolean; readonly isAdding?: boolean; readonly isNarrow?: boolean; + readonly isRetrying?: boolean; readonly isSelected: boolean; readonly onAddClick?: (sourceId: string) => void; readonly onArchiveClick?: (sourceId: string) => void; + readonly onRetryClick?: (sourceId: string) => void; readonly onSelect: () => void; readonly onToggleIncluded?: (sourceId: string, included: boolean) => void; readonly source: SourceView; @@ -30,12 +32,15 @@ export function SourceRow({ onSelect, onToggleIncluded, onArchiveClick, + onRetryClick, chunkTreeHref, isArchiving, + isRetrying = false, }: SourceRowProps): ReactElement { const isReady = source.status === "ready"; const isBusy = source.status === "uploading" || source.status === "parsing"; const isFailed = source.status === "failed"; + const canRetry = isFailed && source.originalFile !== undefined; const isLibrarySource = source.officialLibrary !== undefined; const isRemoteSource = source.kind === "remote"; @@ -104,6 +109,11 @@ export function SourceRow({ ? "Uploading" : "Failed"}

+ {isFailed && source.failureMessage ? ( +

+ {source.failureMessage} +

+ ) : null}
@@ -137,6 +147,26 @@ export function SourceRow({ {isNarrow ? null : "Add"} )} + {canRetry && onRetryClick ? ( + + ) : null} {onArchiveClick && (