Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/app/api/sources/[sourceId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
});

Expand Down Expand Up @@ -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,
},
}));
Expand Down Expand Up @@ -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();
});
});
13 changes: 7 additions & 6 deletions src/app/api/sources/[sourceId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,19 @@ export async function PATCH(
): Promise<NextResponse> {
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)
}
93 changes: 93 additions & 0 deletions src/components/source-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
32 changes: 31 additions & 1 deletion src/components/source-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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";

Expand Down Expand Up @@ -104,6 +109,11 @@ export function SourceRow({
? "Uploading"
: "Failed"}
</p>
{isFailed && source.failureMessage ? (
<p className="mt-0.5 truncate text-[11px] font-medium normal-case tracking-normal text-destructive/80">
{source.failureMessage}
</p>
) : null}
</div>
</button>
<div className="flex shrink-0 items-center justify-self-end">
Expand Down Expand Up @@ -137,6 +147,26 @@ export function SourceRow({
{isNarrow ? null : "Add"}
</button>
)}
{canRetry && onRetryClick ? (
<button
type="button"
disabled={isRetrying || isArchiving}
onClick={(event) => {
event.stopPropagation();
if (isRetrying || isArchiving) return;
onRetryClick(source.id);
}}
className="shrink-0 rounded-lg p-1 text-muted-foreground hover:bg-primary/10 hover:text-primary disabled:cursor-wait disabled:opacity-70"
aria-label={`Retry ${source.title} processing`}
title="Retry processing"
>
{isRetrying ? (
<Spinner className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
) : null}
{onArchiveClick && (
<button
type="button"
Expand Down
34 changes: 34 additions & 0 deletions src/components/sources-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,40 @@ describe("SourcesPanel", () => {
.toBeTruthy();
});

it("shows retry actions for failed source rows", () => {
const onRetrySource = vi.fn();

render(
React.createElement(C, {
sources: [
{
id: "source_1",
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",
},
},
],
onRetrySource,
retryingSourceIds: ["source_1"],
}),
);

const retryButton = screen.getByRole("button", {
name: "Retry lecture.pdf processing",
});
expect((retryButton as HTMLButtonElement).disabled).toBe(true);
expect(
screen.getByText(
"Too many concurrent requests (2/2 active). Please retry after 30 seconds.",
),
).toBeTruthy();
});

it("uploads selected files through the sources API", async () => {
const user = userEvent.setup();
const uploadedSource = {
Expand Down
Loading
Loading