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 && (