Date: Tue, 13 Oct 2026 17:26:41 -0300
Subject: [PATCH 29/34] fix picks
---
.../api/queries/useGetConnectorsQuery.test.ts | 9 ++--
.../app/api/queries/useGetConnectorsQuery.ts | 13 +++---
frontend/app/knowledge/page.tsx | 11 ++++-
src/connectors/url/api.py | 3 ++
src/connectors/url/processor.py | 29 +++++++++++++
src/services/search_service.py | 10 ++---
tests/unit/api/test_search_website_pages.py | 18 +++++++-
tests/unit/test_url_source_api.py | 41 +++++++++++++++++-
tests/unit/test_url_source_processor.py | 42 ++++++++++++++++++-
9 files changed, 154 insertions(+), 22 deletions(-)
diff --git a/frontend/app/api/queries/useGetConnectorsQuery.test.ts b/frontend/app/api/queries/useGetConnectorsQuery.test.ts
index ee1f1a59e7..1865adca56 100644
--- a/frontend/app/api/queries/useGetConnectorsQuery.test.ts
+++ b/frontend/app/api/queries/useGetConnectorsQuery.test.ts
@@ -130,6 +130,7 @@ describe("useGetConnectorsQuery", () => {
});
it("marks an always-connected managed connector connected without OAuth", async () => {
+ let statusRequests = 0;
server.use(
http.get("/api/connectors", () =>
HttpResponse.json(
@@ -145,9 +146,10 @@ describe("useGetConnectorsQuery", () => {
}),
),
),
- http.get("/api/connectors/url/status", () =>
- HttpResponse.json({ connections: [] }),
- ),
+ http.get("/api/connectors/url/status", () => {
+ statusRequests += 1;
+ return HttpResponse.json({ connections: [] });
+ }),
);
const { result } = renderHook(() => useGetConnectorsQuery(), {
@@ -163,6 +165,7 @@ describe("useGetConnectorsQuery", () => {
alwaysConnected: true,
}),
]);
+ expect(statusRequests).toBe(0);
});
it("filters out aws_s3 and ibm_cos for a non-IBM oss deployment", async () => {
diff --git a/frontend/app/api/queries/useGetConnectorsQuery.ts b/frontend/app/api/queries/useGetConnectorsQuery.ts
index 630a965551..758f27c4db 100644
--- a/frontend/app/api/queries/useGetConnectorsQuery.ts
+++ b/frontend/app/api/queries/useGetConnectorsQuery.ts
@@ -199,13 +199,6 @@ export const useGetConnectorsQuery = (
const connectorsWithStatus = await Promise.all(
connectorTypes.map(async (type) => {
const connectorData = connectorsMap[type];
- const statusResponse = await apiClient.get(
- `/connectors/${type}/status`,
- );
-
- let status: Connector["status"] = "not_connected";
- let connectionId: string | undefined;
-
// Determine if this connector requires OAuth based on connector kind
// "oauth" connectors require OAuth flow (Google Drive, OneDrive, SharePoint)
// "bucket" connectors use credential-based auth (Azure Blob, S3, IBM COS)
@@ -227,6 +220,12 @@ export const useGetConnectorsQuery = (
} as Connector;
}
+ const statusResponse = await apiClient.get(
+ `/connectors/${type}/status`,
+ );
+ let status: Connector["status"] = "not_connected";
+ let connectionId: string | undefined;
+
if (statusResponse.status >= 200 && statusResponse.status < 300) {
const statusData = statusResponse.data;
const connections = statusData.connections || [];
diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx
index 20c48909eb..e23e897d73 100644
--- a/frontend/app/knowledge/page.tsx
+++ b/frontend/app/knowledge/page.tsx
@@ -1197,7 +1197,11 @@ function SearchPage() {
rows={gridRows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
- loading={isLoading || deleteDocumentMutation.isPending}
+ loading={
+ isLoading ||
+ deleteDocumentMutation.isPending ||
+ deleteWebsiteSourceMutation.isPending
+ }
gridRef={gridRef}
isCloudBrand={isCloudBrand}
rowSelection="multiple"
@@ -1229,7 +1233,10 @@ function SearchPage() {
description={`Are you sure you want to delete ${selectedRows.length} document${selectedRows.length > 1 ? "s" : ""}?`}
confirmText={selectedRows.length > 1 ? "Delete all" : "Delete"}
onConfirm={handleBulkDelete}
- isLoading={deleteDocumentMutation.isPending}
+ isLoading={
+ deleteDocumentMutation.isPending ||
+ deleteWebsiteSourceMutation.isPending
+ }
>
This will remove all chunks and data associated with these documents.
diff --git a/src/connectors/url/api.py b/src/connectors/url/api.py
index 1e8174f06d..f8da59e0ef 100644
--- a/src/connectors/url/api.py
+++ b/src/connectors/url/api.py
@@ -115,6 +115,7 @@ async def _enqueue(
models_service=task_service.models_service,
page_id=page_id,
source_manifest=source,
+ ingestion_timeout=getattr(task_service, "ingestion_timeout", None),
)
return await task_service.create_custom_task(
user.user_id,
@@ -270,9 +271,11 @@ async def delete_source(
async def delete_page(
source_id: str,
page_id: str,
+ task_service=Depends(get_task_service),
user: User = Depends(require_permission("knowledge:delete:own")),
):
source = await _owned(source_id, user)
+ source = await _recover_terminal_processing_source(source, task_service)
if source.get("status") == "processing":
raise HTTPException(409, "A crawl is already running; wait before disabling a page")
page = await get_page_manifest(source_id, page_id)
diff --git a/src/connectors/url/processor.py b/src/connectors/url/processor.py
index c8f620fd3d..d63317c11b 100644
--- a/src/connectors/url/processor.py
+++ b/src/connectors/url/processor.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
import hashlib
import os
import tempfile
@@ -26,6 +27,9 @@
upsert_source_manifest,
)
+CRAWL_INGESTION_HEADROOM_SECONDS = 5 * 60
+CRAWL_SHUTDOWN_GRACE_SECONDS = 30
+
def _document_id(source_id: str, canonical_url: str) -> str:
return hashlib.sha256(f"{source_id}\0{canonical_url}".encode()).hexdigest()
@@ -44,6 +48,7 @@ def __init__(
models_service,
page_id: str | None = None,
source_manifest: dict[str, Any] | None = None,
+ ingestion_timeout: int | None = None,
):
super().__init__(document_service=document_service, models_service=models_service)
self.source_id = source_id
@@ -54,6 +59,7 @@ def __init__(
self.page_id = page_id
self.source_manifest = dict(source_manifest) if source_manifest is not None else None
self._active_source: dict[str, Any] | None = None
+ self.ingestion_timeout = ingestion_timeout
async def _source_is_active(self) -> dict[str, Any]:
source = await get_source_manifest(self.source_id)
@@ -103,6 +109,13 @@ async def _finalize_unhandled_failure(
async def process_item(self, upload_task: UploadTask, item: str, file_task: FileTask) -> None:
try:
await self._process_item(upload_task, item, file_task)
+ except asyncio.CancelledError:
+ await asyncio.shield(
+ self._finalize_unhandled_failure(
+ "Website crawl was cancelled.", upload_task, file_task
+ )
+ )
+ raise
except Exception as exc:
await self._finalize_unhandled_failure(
str(exc) or "Website crawl failed before it could start.",
@@ -154,6 +167,22 @@ async def _process_item(self, upload_task: UploadTask, item: str, file_task: Fil
elif source_is_established and source.get("resync_behavior") == "root":
spec_values.update(scope="page", max_pages=1, max_depth=0)
+ if self.ingestion_timeout is not None:
+ # TaskService applies one timeout to crawling and ingestion. Keep a
+ # bounded portion for page ingestion after Scrapy returns, plus
+ # the crawler subprocess's shutdown grace period.
+ crawl_budget_seconds = (
+ self.ingestion_timeout
+ - CRAWL_INGESTION_HEADROOM_SECONDS
+ - CRAWL_SHUTDOWN_GRACE_SECONDS
+ )
+ if crawl_budget_seconds < 60:
+ raise ValueError("Task timeout is too short for a website crawl")
+ crawl_minutes = crawl_budget_seconds // 60
+ spec_values["max_crawl_minutes"] = min(
+ int(spec_values["max_crawl_minutes"]), crawl_minutes
+ )
+
try:
result = await crawl(CrawlSpec(**spec_values))
if not result.pages and not result.reason and not result.capped:
diff --git a/src/services/search_service.py b/src/services/search_service.py
index 189d90ec5d..924178c02c 100644
--- a/src/services/search_service.py
+++ b/src/services/search_service.py
@@ -904,18 +904,16 @@ async def search(
set_auth_context(user_id, jwt_token)
- # Set filters and limit in context if provided
- if filters:
- from auth_context import set_search_filters
-
- set_search_filters(filters)
-
from auth_context import (
set_score_threshold,
+ set_search_filters,
set_search_limit,
set_search_result_mode,
)
+ # Context variables survive within a task. Reset filters for every
+ # search so an unfiltered call cannot inherit a prior request's scope.
+ set_search_filters(filters or {})
set_search_limit(limit)
set_score_threshold(score_threshold)
set_search_result_mode(result_mode)
diff --git a/tests/unit/api/test_search_website_pages.py b/tests/unit/api/test_search_website_pages.py
index 92ad35526e..deedb60c4b 100644
--- a/tests/unit/api/test_search_website_pages.py
+++ b/tests/unit/api/test_search_website_pages.py
@@ -5,7 +5,12 @@
from api import search as search_api
from api.search import SearchBody, _website_page_search_result
-from services.search_service import _add_result_mode_filter, _build_search_filter_clauses
+from auth_context import get_search_filters, set_search_filters
+from services.search_service import (
+ SearchService,
+ _add_result_mode_filter,
+ _build_search_filter_clauses,
+)
def test_url_source_filter_matches_current_and_legacy_keyword_mappings():
@@ -30,6 +35,17 @@ def test_knowledge_sources_filter_excludes_child_pages_before_ranking():
assert clauses == [{"bool": {"must_not": [{"exists": {"field": "web_page_id"}}]}}]
+@pytest.mark.asyncio
+async def test_search_clears_filters_when_the_request_has_none():
+ service = object.__new__(SearchService)
+ service.search_tool = AsyncMock(return_value={})
+ set_search_filters({"web_source_ids": ["source-1"]})
+
+ await service.search("guide", filters=None)
+
+ assert get_search_filters() == {}
+
+
@pytest.mark.asyncio
async def test_website_page_search_reads_zero_chunk_manifest_rows(monkeypatch):
source = {"id": "source-1", "owner_id": "user-1"}
diff --git a/tests/unit/test_url_source_api.py b/tests/unit/test_url_source_api.py
index c4bf4c7431..ee3a68b5aa 100644
--- a/tests/unit/test_url_source_api.py
+++ b/tests/unit/test_url_source_api.py
@@ -143,18 +143,55 @@ async def test_disabling_a_page_updates_manifests_and_removes_chunks(monkeypatch
@pytest.mark.asyncio
async def test_disabling_page_rejects_a_running_source(monkeypatch):
- source = _source(status="processing")
+ source = _source(status="processing", last_task_id="task-1")
delete_chunks = AsyncMock()
+ task_service = type(
+ "TaskService",
+ (),
+ {"get_upload_task": lambda *_: type("Task", (), {"status": api.TaskStatus.RUNNING})()},
+ )()
monkeypatch.setattr(api, "get_source_manifest", AsyncMock(return_value=source))
monkeypatch.setattr(api, "delete_page_chunks", delete_chunks)
with pytest.raises(Exception, match="crawl is already running") as error:
- await api.delete_page(source["id"], "page-1", user=_user(source))
+ await api.delete_page(source["id"], "page-1", task_service=task_service, user=_user(source))
assert getattr(error.value, "status_code", None) == 409
delete_chunks.assert_not_awaited()
+@pytest.mark.asyncio
+async def test_disabling_page_recovers_a_source_with_a_terminal_task(monkeypatch):
+ source = _source(status="processing", last_task_id="task-1")
+ page = {
+ "web_source_id": source["id"],
+ "web_page_id": "page-1",
+ "document_id": "web-page:source-1:page-1",
+ "content_document_id": "document-1",
+ "status": "active",
+ }
+ task_service = type(
+ "TaskService",
+ (),
+ {"get_upload_task": lambda *_: type("Task", (), {"status": api.TaskStatus.FAILED})()},
+ )()
+ persist_source = AsyncMock()
+ monkeypatch.setattr(api, "get_source_manifest", AsyncMock(return_value=source))
+ monkeypatch.setattr(api, "get_page_manifest", AsyncMock(return_value=page))
+ monkeypatch.setattr(api, "list_page_manifests", AsyncMock(return_value=[page]))
+ monkeypatch.setattr(api, "upsert_source_manifest", persist_source)
+ monkeypatch.setattr(api, "upsert_page_manifest", AsyncMock())
+ monkeypatch.setattr(api, "delete_page_chunks", AsyncMock())
+
+ result = await api.delete_page(
+ source["id"], "page-1", task_service=task_service, user=_user(source)
+ )
+
+ assert result == {"id": "page-1", "status": "disabled"}
+ assert source["status"] == "failed"
+ assert persist_source.await_count == 2
+
+
@pytest.mark.asyncio
async def test_sync_recovers_a_processing_source_when_its_task_is_terminal(monkeypatch):
source = _source(status="processing", last_task_id="task-1")
diff --git a/tests/unit/test_url_source_processor.py b/tests/unit/test_url_source_processor.py
index 7f195e18a7..5d4c6bd60f 100644
--- a/tests/unit/test_url_source_processor.py
+++ b/tests/unit/test_url_source_processor.py
@@ -1,5 +1,6 @@
"""OpenSearch-manifest coverage for the managed URL source processor."""
+import asyncio
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock
@@ -29,7 +30,9 @@ def _source(**overrides) -> dict:
return source
-def _processor(source: dict, *, page_id: str | None = None) -> WebsiteSourceProcessor:
+def _processor(
+ source: dict, *, page_id: str | None = None, ingestion_timeout: int | None = None
+) -> WebsiteSourceProcessor:
return WebsiteSourceProcessor(
source_id=source["id"],
owner_id=source["owner_id"],
@@ -39,6 +42,7 @@ def _processor(source: dict, *, page_id: str | None = None) -> WebsiteSourceProc
document_service=None,
models_service=None,
page_id=page_id,
+ ingestion_timeout=ingestion_timeout,
)
@@ -165,6 +169,42 @@ async def test_initial_crawl_keeps_saved_scope_when_future_resync_is_root_only(m
assert spec.max_pages == 250
+@pytest.mark.asyncio
+async def test_crawl_leaves_task_timeout_headroom_for_page_ingestion(monkeypatch):
+ source = _source(
+ crawl_settings={"seed_url": "https://docs.example.com/", "max_crawl_minutes": 15}
+ )
+ crawl = AsyncMock(
+ return_value=CrawlResult((), complete=False, capped=False, reason="network down")
+ )
+ monkeypatch.setattr(processor_module, "get_source_manifest", AsyncMock(return_value=source))
+ monkeypatch.setattr(processor_module, "list_page_manifests", AsyncMock(return_value=[]))
+ monkeypatch.setattr(processor_module, "upsert_source_manifest", AsyncMock())
+ monkeypatch.setattr(processor_module, "crawl", crawl)
+
+ task, file_task = UploadTask(task_id="task-1", total_files=1), FileTask(file_path="source-1")
+ await _processor(source, ingestion_timeout=600).process_item(
+ task, file_task.file_path, file_task
+ )
+
+ assert crawl.await_args.args[0].max_crawl_minutes == 4
+
+
+@pytest.mark.asyncio
+async def test_cancellation_finalizes_the_source_before_propagating(monkeypatch):
+ source = _source()
+ processor = _processor(source)
+ finalize = AsyncMock()
+ monkeypatch.setattr(processor, "_process_item", AsyncMock(side_effect=asyncio.CancelledError()))
+ monkeypatch.setattr(processor, "_finalize_unhandled_failure", finalize)
+ task, file_task = UploadTask(task_id="task-1", total_files=1), FileTask(file_path="source-1")
+
+ with pytest.raises(asyncio.CancelledError):
+ await processor.process_item(task, file_task.file_path, file_task)
+
+ finalize.assert_awaited_once_with("Website crawl was cancelled.", task, file_task)
+
+
@pytest.mark.asyncio
async def test_failed_resync_keeps_existing_page_manifests_and_source_active(monkeypatch):
source = _source(last_successful_sync_at=datetime.now(UTC).isoformat())
From a8c9ea618a256ee1b7eac1aec464339dbd4dccb3 Mon Sep 17 00:00:00 2001
From: Lucas Oliveira
Date: Tue, 13 Oct 2026 18:05:09 -0300
Subject: [PATCH 30/34] add e2e tests
---
.github/workflows/test-ci.yml | 2 +-
frontend/tests/README.md | 7 ++
.../tests/core/managed_url_connector.spec.ts | 83 +++++++++++++++++++
3 files changed, 91 insertions(+), 1 deletion(-)
create mode 100644 frontend/tests/core/managed_url_connector.spec.ts
diff --git a/.github/workflows/test-ci.yml b/.github/workflows/test-ci.yml
index 5ccd25178a..82bbbd119c 100644
--- a/.github/workflows/test-ci.yml
+++ b/.github/workflows/test-ci.yml
@@ -454,7 +454,7 @@ jobs:
- name: Setup E2E infrastructure
run: |
chmod +x scripts/setup-e2e.sh
- OPENRAG_VERSION="${CI_IMAGE_TAG}" ./scripts/setup-e2e.sh
+ OPENRAG_VERSION="${CI_IMAGE_TAG}" OPENRAG_URL_CONNECTOR_ENABLED=true ./scripts/setup-e2e.sh
- name: Run Playwright tests (shard ${{ matrix.shard }}/${{ matrix.total_shards }})
working-directory: frontend
diff --git a/frontend/tests/README.md b/frontend/tests/README.md
index 2d1cf26110..5e016a68ea 100644
--- a/frontend/tests/README.md
+++ b/frontend/tests/README.md
@@ -18,6 +18,13 @@ We provide a setup script that automates the process of starting the full stack
./scripts/setup-e2e.sh
```
+The managed website-connection E2E test is behind the URL connector feature
+flag. Enable it when starting the stack:
+
+```bash
+OPENRAG_URL_CONNECTOR_ENABLED=true ./scripts/setup-e2e.sh
+```
+
**What this script does:**
- Pre-creates required directories (e.g., `langflow-data`).
- Starts the full stack using `make dev-cpu`.
diff --git a/frontend/tests/core/managed_url_connector.spec.ts b/frontend/tests/core/managed_url_connector.spec.ts
new file mode 100644
index 0000000000..d6120229dd
--- /dev/null
+++ b/frontend/tests/core/managed_url_connector.spec.ts
@@ -0,0 +1,83 @@
+import { expect, test } from "../utils/fixtures";
+import { navigateToKnowledge } from "../utils/navigation";
+
+test("managed URL connector creates a one-page website connection", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+
+ const name = `Playwright website ${Date.now()}`;
+ const startingUrl = "https://example.com/";
+ let sourceId: string | undefined;
+
+ try {
+ await navigateToKnowledge(page);
+
+ await page.getByRole("button", { name: /add knowledge/i }).click();
+ await page.getByRole("menuitem", { name: "URL" }).click();
+
+ const dialog = page.getByRole("dialog", {
+ name: "Add a Website connection",
+ });
+ await expect(dialog).toBeVisible();
+
+ await dialog.getByLabel("Connection name").fill(name);
+ await dialog.getByLabel("Starting URL").fill(startingUrl);
+ await dialog.getByRole("button", { name: "This page only" }).click();
+ await dialog
+ .getByRole("button", { name: "Advanced crawl settings" })
+ .click();
+ await dialog.getByLabel("Change detection").click();
+ await page.getByRole("option", { name: "Always re-ingest" }).click();
+
+ await dialog.getByRole("button", { name: "Continue" }).click();
+ await expect(
+ dialog.getByRole("heading", { name: "How should re-syncs work?" }),
+ ).toBeVisible();
+
+ const createResponse = page.waitForResponse(
+ (response) =>
+ response.url().endsWith("/api/connectors/url/sources") &&
+ response.request().method() === "POST",
+ );
+ await dialog.getByRole("button", { name: "Ingest URL" }).click();
+
+ const response = await createResponse;
+ expect(response.ok()).toBe(true);
+ expect(response.request().postDataJSON()).toMatchObject({
+ name,
+ starting_url: startingUrl,
+ scope: "page",
+ max_depth: 0,
+ max_pages: 1,
+ change_detection: "always_reingest",
+ });
+ const source = (await response.json()) as { id: string; name: string };
+ sourceId = source.id;
+ expect(source.name).toBe(name);
+ await expect(dialog).toBeHidden();
+ await expect(page.getByText("Website crawl started")).toBeVisible();
+
+ const sourcesResponse = await page.request.get(
+ "/api/connectors/url/sources",
+ );
+ expect(sourcesResponse.ok()).toBe(true);
+ const sources = (await sourcesResponse.json()) as {
+ sources: { id: string; name: string; change_detection: string }[];
+ };
+ expect(sources.sources).toContainEqual(
+ expect.objectContaining({
+ id: sourceId,
+ name,
+ change_detection: "always_reingest",
+ }),
+ );
+ } finally {
+ if (sourceId) {
+ const deleteResponse = await page.request.delete(
+ `/api/connectors/url/sources/${sourceId}`,
+ );
+ expect(deleteResponse.ok()).toBe(true);
+ }
+ }
+});
From e04c67f4511edc3c08cff85122119b2e9ff09301 Mon Sep 17 00:00:00 2001
From: Lucas Oliveira
Date: Fri, 23 Oct 2026 18:08:41 -0300
Subject: [PATCH 31/34] open url modal as state not route
---
.../app/settings/_components/connector-card.tsx | 6 +++---
.../_components/connector-cards.test.tsx | 8 ++++++--
.../app/settings/_components/connector-cards.tsx | 16 +++++++++++-----
frontend/app/upload/[provider]/page.tsx | 6 +-----
frontend/lib/connectors/registry.ts | 1 -
5 files changed, 21 insertions(+), 16 deletions(-)
diff --git a/frontend/app/settings/_components/connector-card.tsx b/frontend/app/settings/_components/connector-card.tsx
index b20ba22ae7..96c2cc47df 100644
--- a/frontend/app/settings/_components/connector-card.tsx
+++ b/frontend/app/settings/_components/connector-card.tsx
@@ -34,7 +34,7 @@ interface ConnectorCardProps {
isDisconnecting: boolean;
onConnect: (connector: Connector) => void;
onDisconnect: (connector: Connector) => void;
- onNavigateToKnowledge: (connector: Connector) => void;
+ onAddKnowledge: (connector: Connector) => void;
/** Optional: open a connector-specific settings/edit dialog */
onConfigure?: (connector: Connector) => void;
}
@@ -45,7 +45,7 @@ export default function ConnectorCard({
isDisconnecting,
onConnect,
onDisconnect,
- onNavigateToKnowledge,
+ onAddKnowledge,
onConfigure,
}: ConnectorCardProps) {
const isCloudBrand = useIsCloudBrand();
@@ -125,7 +125,7 @@ export default function ConnectorCard({