;
+# beyond this the text is a content dump, not a label.
+_MAX_ANCHOR_TEXT = 200
+
+# Context cap: nearest-ancestor text for icon-only anchors. Person/company
+# cards ("Jane Doe General Partner") fit well under this; anything longer is
+# a section dump and gets truncated rather than dropped.
+_MAX_CONTEXT = 120
+
+
+def _collapse(text: str) -> str:
+ return _WHITESPACE_RE.sub(" ", text).strip()
+
+
+def _node_text(node: Any) -> str:
+ # itertext + space-join keeps a word boundary between block elements,
+ # where text_content() would glue "Jane DoePartner" together.
+ return _collapse(" ".join(node.itertext()))
+
+
+def _anchor_label(anchor: Any) -> str:
+ """Best label for an anchor: its text, else aria-label/title, else img alt."""
+ text = _node_text(anchor)
+ if text:
+ return text
+ for attr in ("aria-label", "title"):
+ value = _collapse(anchor.get(attr) or "")
+ if value:
+ return value
+ for alt in anchor.xpath(".//img/@alt"):
+ value = _collapse(str(alt))
+ if value:
+ return value
+ return ""
+
+
+def _anchor_context(anchor: Any) -> str:
+ """Nearest ancestor's text for unlabeled anchors (icon-only social links).
+
+ Team/profile cards put the person's name next to — not inside — the icon
+ link, so the closest ancestor with any text is the entity label we want.
+ """
+ node = anchor.getparent()
+ while node is not None:
+ text = _node_text(node)
+ if text:
+ return text[:_MAX_CONTEXT]
+ node = node.getparent()
+ return ""
+
+
+def extract_link_records(page_html: str | None, base_url: str) -> list[dict[str, str]]:
+ """Structured ```` inventory: ``{url, text, context, rel, kind}`` per target.
+
+ ``kind`` is one of ``internal`` (same site as ``base_url``), ``external``,
+ ``social`` (known profile host), ``email`` (``mailto:``), or ``tel``. http(s)
+ targets are absolutized against ``base_url`` and fragment-stripped; the
+ page's own URL is dropped. De-duplicated by target URL (first-seen order),
+ keeping the first non-empty anchor text so a nav logo link doesn't shadow
+ the labeled one.
+
+ ``text`` falls back to aria-label/title/img-alt for icon-only anchors.
+ ``context`` (social/email/tel only) is the nearest ancestor's text — team
+ pages label a person *next to* their LinkedIn icon, not inside it, so this
+ is what ties a profile URL to its entity.
+ """
+ if not page_html or not page_html.strip():
+ return []
+ try:
+ root = lxml_html.fromstring(page_html)
+ except (ParserError, ValueError):
+ return []
+
+ self_url, _ = urldefrag(base_url)
+ base_host = host_of(base_url)
+ records: dict[str, dict[str, str]] = {}
+
+ for anchor in root.xpath("//a[@href]"):
+ href = str(anchor.get("href", "")).strip()
+ low = href.lower()
+ # unquote: hrefs URL-encode spaces etc. ("tel:+1%20408-629-1770")
+ if low.startswith("mailto:"):
+ target = unquote(urlsplit(href).path.split("?")[0]).strip()
+ kind = "email"
+ elif low.startswith("tel:"):
+ target = unquote(urlsplit(href).path).strip()
+ kind = "tel"
+ else:
+ target, _ = urldefrag(urljoin(base_url, href))
+ if urlsplit(target).scheme not in ("http", "https"):
+ continue
+ if target == self_url:
+ continue
+ host = (urlsplit(target).hostname or "").lower()
+ if is_social_host(host):
+ kind = "social"
+ elif host_of(target) == base_host:
+ kind = "internal"
+ else:
+ kind = "external"
+ if not target:
+ continue
+
+ text = _anchor_label(anchor)[:_MAX_ANCHOR_TEXT]
+ record = {
+ "url": target,
+ "text": text,
+ "rel": str(anchor.get("rel", "")).strip(),
+ "kind": kind,
+ }
+ # Context only where entity attribution matters; internal/external nav
+ # context is boilerplate that would bloat every item.
+ if kind in ("social", "email", "tel"):
+ record["context"] = _anchor_context(anchor) if not text else ""
+ existing = records.get(target)
+ if existing is None:
+ records[target] = record
+ elif not existing["text"] and text:
+ existing["text"] = text
+ if "context" in existing:
+ existing["context"] = ""
+ return list(records.values())
+
+
+def extract_links(page_html: str | None, base_url: str) -> list[str]:
+ """Absolute, http(s), fragment-free, de-duplicated `` `` targets.
+
+ URL-only view of ``extract_link_records`` for callers that just need the
+ frontier; first-seen order is preserved to keep it stable.
+ """
+ return [
+ record["url"]
+ for record in extract_link_records(page_html, base_url)
+ if record["kind"] not in ("email", "tel")
+ ]
+
+
+def host_of(url: str) -> str:
+ """Lowercased host with a leading ``www.`` removed, for same-site matching."""
+ host = (urlsplit(url).hostname or "").lower()
+ return host[4:] if host.startswith("www.") else host
diff --git a/surfsense_backend/app/retriever/chunks_hybrid_search.py b/surfsense_backend/app/retriever/chunks_hybrid_search.py
index 5e5edec2e..8f247b661 100644
--- a/surfsense_backend/app/retriever/chunks_hybrid_search.py
+++ b/surfsense_backend/app/retriever/chunks_hybrid_search.py
@@ -14,29 +14,29 @@ def _instrument_search(mode: str):
def _decorator(func):
@functools.wraps(func)
async def _wrapper(
- self, query_text: str, top_k: int, search_space_id: int, *args, **kwargs
+ self, query_text: str, top_k: int, workspace_id: int, *args, **kwargs
):
t0 = time.perf_counter()
with ot.kb_search_span(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
query_chars=len(query_text),
extra={"search.surface": "chunks", "search.mode": mode},
) as sp:
try:
result = await func(
- self, query_text, top_k, search_space_id, *args, **kwargs
+ self, query_text, top_k, workspace_id, *args, **kwargs
)
except Exception:
ot_metrics.record_kb_search_duration(
(time.perf_counter() - t0) * 1000,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
surface="chunks",
)
raise
sp.set_attribute("result.count", len(result))
ot_metrics.record_kb_search_duration(
(time.perf_counter() - t0) * 1000,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
surface="chunks",
)
return result
@@ -61,7 +61,7 @@ class ChucksHybridSearchRetriever:
self,
query_text: str,
top_k: int,
- search_space_id: int,
+ workspace_id: int,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> list:
@@ -71,7 +71,7 @@ class ChucksHybridSearchRetriever:
Args:
query_text: The search query text
top_k: Number of results to return
- search_space_id: The search space ID to search within
+ workspace_id: The workspace ID to search within
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -96,12 +96,12 @@ class ChucksHybridSearchRetriever:
time.perf_counter() - t_embed,
)
- # Build the query filtered by search space
+ # Build the query filtered by workspace
query = (
select(Chunk)
- .options(joinedload(Chunk.document).joinedload(Document.search_space))
+ .options(joinedload(Chunk.document).joinedload(Document.workspace))
.join(Document, Chunk.document_id == Document.id)
- .where(Document.search_space_id == search_space_id)
+ .where(Document.workspace_id == workspace_id)
)
# Add time-based filtering if provided
@@ -122,7 +122,7 @@ class ChucksHybridSearchRetriever:
time.perf_counter() - t_db,
len(chunks),
time.perf_counter() - t0,
- search_space_id,
+ workspace_id,
)
return chunks
@@ -132,7 +132,7 @@ class ChucksHybridSearchRetriever:
self,
query_text: str,
top_k: int,
- search_space_id: int,
+ workspace_id: int,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> list:
@@ -142,7 +142,7 @@ class ChucksHybridSearchRetriever:
Args:
query_text: The search query text
top_k: Number of results to return
- search_space_id: The search space ID to search within
+ workspace_id: The workspace ID to search within
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -161,12 +161,12 @@ class ChucksHybridSearchRetriever:
tsvector = func.to_tsvector("english", Chunk.content)
tsquery = func.plainto_tsquery("english", query_text)
- # Build the query filtered by search space
+ # Build the query filtered by workspace
query = (
select(Chunk)
- .options(joinedload(Chunk.document).joinedload(Document.search_space))
+ .options(joinedload(Chunk.document).joinedload(Document.workspace))
.join(Document, Chunk.document_id == Document.id)
- .where(Document.search_space_id == search_space_id)
+ .where(Document.workspace_id == workspace_id)
.where(
tsvector.op("@@")(tsquery)
) # Only include results that match the query
@@ -188,7 +188,7 @@ class ChucksHybridSearchRetriever:
"[chunk_search] full_text_search in %.3fs results=%d space=%d",
time.perf_counter() - t0,
len(chunks),
- search_space_id,
+ workspace_id,
)
return chunks
@@ -198,7 +198,7 @@ class ChucksHybridSearchRetriever:
self,
query_text: str,
top_k: int,
- search_space_id: int,
+ workspace_id: int,
document_type: str | list[str] | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -213,7 +213,7 @@ class ChucksHybridSearchRetriever:
Args:
query_text: The search query text
top_k: Number of documents to return
- search_space_id: The search space ID to search within
+ workspace_id: The workspace ID to search within
document_type: Optional document type to filter results (e.g., "FILE", "CRAWLED_URL")
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -252,10 +252,10 @@ class ChucksHybridSearchRetriever:
tsvector = func.to_tsvector("english", Chunk.content)
tsquery = func.plainto_tsquery("english", query_text)
- # Base conditions for chunk filtering - search space is required.
+ # Base conditions for chunk filtering - workspace is required.
# Exclude documents in "deleting" state (background deletion in progress).
base_conditions = [
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
func.coalesce(Document.status["state"].astext, "ready") != "deleting",
]
@@ -284,7 +284,7 @@ class ChucksHybridSearchRetriever:
if end_date is not None:
base_conditions.append(Document.updated_at <= end_date)
- # CTE for semantic search filtered by search space
+ # CTE for semantic search filtered by workspace
semantic_search_cte = (
select(
Chunk.id,
@@ -302,7 +302,7 @@ class ChucksHybridSearchRetriever:
.cte("semantic_search")
)
- # CTE for keyword search filtered by search space
+ # CTE for keyword search filtered by workspace
keyword_search_cte = (
select(
Chunk.id,
@@ -355,7 +355,7 @@ class ChucksHybridSearchRetriever:
"[chunk_search] hybrid_search RRF query in %.3fs results=%d space=%d type=%s",
time.perf_counter() - t_rrf,
len(chunks_with_scores),
- search_space_id,
+ workspace_id,
document_type,
)
@@ -493,7 +493,7 @@ class ChucksHybridSearchRetriever:
"[chunk_search] hybrid_search TOTAL in %.3fs docs=%d space=%d type=%s",
time.perf_counter() - t0,
len(final_docs),
- search_space_id,
+ workspace_id,
document_type,
)
return final_docs
diff --git a/surfsense_backend/app/retriever/documents_hybrid_search.py b/surfsense_backend/app/retriever/documents_hybrid_search.py
index d856e93cf..9daa2d510 100644
--- a/surfsense_backend/app/retriever/documents_hybrid_search.py
+++ b/surfsense_backend/app/retriever/documents_hybrid_search.py
@@ -13,29 +13,29 @@ def _instrument_search(mode: str):
def _decorator(func):
@functools.wraps(func)
async def _wrapper(
- self, query_text: str, top_k: int, search_space_id: int, *args, **kwargs
+ self, query_text: str, top_k: int, workspace_id: int, *args, **kwargs
):
t0 = time.perf_counter()
with ot.kb_search_span(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
query_chars=len(query_text),
extra={"search.surface": "documents", "search.mode": mode},
) as sp:
try:
result = await func(
- self, query_text, top_k, search_space_id, *args, **kwargs
+ self, query_text, top_k, workspace_id, *args, **kwargs
)
except Exception:
ot_metrics.record_kb_search_duration(
(time.perf_counter() - t0) * 1000,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
surface="documents",
)
raise
sp.set_attribute("result.count", len(result))
ot_metrics.record_kb_search_duration(
(time.perf_counter() - t0) * 1000,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
surface="documents",
)
return result
@@ -60,7 +60,7 @@ class DocumentHybridSearchRetriever:
self,
query_text: str,
top_k: int,
- search_space_id: int,
+ workspace_id: int,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> list:
@@ -70,7 +70,7 @@ class DocumentHybridSearchRetriever:
Args:
query_text: The search query text
top_k: Number of results to return
- search_space_id: The search space ID to search within
+ workspace_id: The workspace ID to search within
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -90,11 +90,11 @@ class DocumentHybridSearchRetriever:
embedding_model = config.embedding_model_instance
query_embedding = embedding_model.embed(query_text)
- # Build the query filtered by search space
+ # Build the query filtered by workspace
query = (
select(Document)
- .options(joinedload(Document.search_space))
- .where(Document.search_space_id == search_space_id)
+ .options(joinedload(Document.workspace))
+ .where(Document.workspace_id == workspace_id)
)
# Add time-based filtering if provided
@@ -115,7 +115,7 @@ class DocumentHybridSearchRetriever:
"[doc_search] vector_search in %.3fs results=%d space=%d",
time.perf_counter() - t0,
len(documents),
- search_space_id,
+ workspace_id,
)
return documents
@@ -125,7 +125,7 @@ class DocumentHybridSearchRetriever:
self,
query_text: str,
top_k: int,
- search_space_id: int,
+ workspace_id: int,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> list:
@@ -135,7 +135,7 @@ class DocumentHybridSearchRetriever:
Args:
query_text: The search query text
top_k: Number of results to return
- search_space_id: The search space ID to search within
+ workspace_id: The workspace ID to search within
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -154,11 +154,11 @@ class DocumentHybridSearchRetriever:
tsvector = func.to_tsvector("english", Document.content)
tsquery = func.plainto_tsquery("english", query_text)
- # Build the query filtered by search space
+ # Build the query filtered by workspace
query = (
select(Document)
- .options(joinedload(Document.search_space))
- .where(Document.search_space_id == search_space_id)
+ .options(joinedload(Document.workspace))
+ .where(Document.workspace_id == workspace_id)
.where(
tsvector.op("@@")(tsquery)
) # Only include results that match the query
@@ -180,7 +180,7 @@ class DocumentHybridSearchRetriever:
"[doc_search] full_text_search in %.3fs results=%d space=%d",
time.perf_counter() - t0,
len(documents),
- search_space_id,
+ workspace_id,
)
return documents
@@ -190,7 +190,7 @@ class DocumentHybridSearchRetriever:
self,
query_text: str,
top_k: int,
- search_space_id: int,
+ workspace_id: int,
document_type: str | list[str] | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -205,7 +205,7 @@ class DocumentHybridSearchRetriever:
Args:
query_text: The search query text
top_k: Number of documents to return
- search_space_id: The search space ID to search within
+ workspace_id: The workspace ID to search within
document_type: Optional document type to filter results (e.g., "FILE", "CRAWLED_URL")
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -232,10 +232,10 @@ class DocumentHybridSearchRetriever:
tsvector = func.to_tsvector("english", Document.content)
tsquery = func.plainto_tsquery("english", query_text)
- # Base conditions for document filtering - search space is required.
+ # Base conditions for document filtering - workspace is required.
# Exclude documents in "deleting" state (background deletion in progress).
base_conditions = [
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
func.coalesce(Document.status["state"].astext, "ready") != "deleting",
]
@@ -264,7 +264,7 @@ class DocumentHybridSearchRetriever:
if end_date is not None:
base_conditions.append(Document.updated_at <= end_date)
- # CTE for semantic search filtered by search space
+ # CTE for semantic search filtered by workspace
semantic_search_cte = select(
Document.id,
func.rank()
@@ -278,7 +278,7 @@ class DocumentHybridSearchRetriever:
.cte("semantic_search")
)
- # CTE for keyword search filtered by search space
+ # CTE for keyword search filtered by workspace
keyword_search_cte = (
select(
Document.id,
@@ -317,7 +317,7 @@ class DocumentHybridSearchRetriever:
Document.id
== func.coalesce(semantic_search_cte.c.id, keyword_search_cte.c.id),
)
- .options(joinedload(Document.search_space))
+ .options(joinedload(Document.workspace))
.order_by(text("score DESC"))
.limit(top_k)
)
@@ -419,7 +419,7 @@ class DocumentHybridSearchRetriever:
"[doc_search] hybrid_search TOTAL in %.3fs docs=%d space=%d type=%s",
time.perf_counter() - t0,
len(final_docs),
- search_space_id,
+ workspace_id,
document_type,
)
return final_docs
diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py
index caa1a2546..1a5b182b8 100644
--- a/surfsense_backend/app/routes/__init__.py
+++ b/surfsense_backend/app/routes/__init__.py
@@ -1,6 +1,13 @@
from fastapi import APIRouter, Depends
+# Import verb namespaces for their registration side effects before the door builds.
+import app.capabilities.google_maps
+import app.capabilities.google_search
+import app.capabilities.reddit
+import app.capabilities.web
+import app.capabilities.youtube # noqa: F401
from app.automations.api import router as automations_router
+from app.capabilities.core.access.rest import build_capabilities_router
from app.file_storage.api import router as file_storage_router
from app.gateway import require_gateway_enabled
from app.notifications.api import router as notifications_router
@@ -61,17 +68,17 @@ from .rbac_routes import router as rbac_router
from .reports_routes import router as reports_router
from .sandbox_routes import router as sandbox_router
from .search_source_connectors_routes import router as search_source_connectors_router
-from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router
from .stripe_routes import router as stripe_router
from .team_memory_routes import router as team_memory_router
from .teams_add_connector_route import router as teams_add_connector_router
from .video_presentations_routes import router as video_presentations_router
+from .workspaces_routes import router as workspaces_router
from .youtube_routes import router as youtube_router
router = APIRouter()
-router.include_router(search_spaces_router)
+router.include_router(workspaces_router)
router.include_router(rbac_router) # RBAC routes for roles, members, invites
router.include_router(editor_router)
router.include_router(export_router)
@@ -92,7 +99,7 @@ router.include_router(agent_revert_router) # POST /threads/{id}/revert/{action_
router.include_router(agent_action_log_router) # GET /threads/{id}/actions
router.include_router(
agent_permissions_router
-) # CRUD for /searchspaces/{id}/agent/permissions/rules
+) # CRUD for /workspaces/{id}/agent/permissions/rules
router.include_router(agent_flags_router) # GET /agent/flags
router.include_router(sandbox_router) # Sandbox file downloads (Daytona)
router.include_router(chat_comments_router)
@@ -135,6 +142,7 @@ router.include_router(stripe_router) # Stripe checkout for additional page pack
router.include_router(youtube_router) # YouTube playlist resolution
router.include_router(prompts_router)
router.include_router(memory_router) # User personal memory (memory.md style)
-router.include_router(team_memory_router) # Search-space team memory
+router.include_router(team_memory_router) # Workspace team memory
router.include_router(automations_router) # Automations CRUD + run history
router.include_router(file_storage_router) # Original file metadata + download
+router.include_router(build_capabilities_router()) # Scraper-API capability doors (05)
diff --git a/surfsense_backend/app/routes/agent_action_log_route.py b/surfsense_backend/app/routes/agent_action_log_route.py
index 72086b8ae..582927d76 100644
--- a/surfsense_backend/app/routes/agent_action_log_route.py
+++ b/surfsense_backend/app/routes/agent_action_log_route.py
@@ -55,7 +55,7 @@ class AgentActionRead(BaseModel):
id: int
thread_id: int
user_id: str | None
- search_space_id: int
+ workspace_id: int
tool_name: str
args: dict[str, Any] | None
result_id: str | None
@@ -116,7 +116,7 @@ async def list_thread_actions(
"""List agent actions for a thread, newest first.
Authorization:
- * Caller must be a member of the thread's search space with
+ * Caller must be a member of the thread's workspace with
``CHATS_READ`` permission.
Pagination:
@@ -133,7 +133,7 @@ async def list_thread_actions(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to view this thread's action log.",
)
@@ -169,7 +169,7 @@ async def list_thread_actions(
id=row.id,
thread_id=row.thread_id,
user_id=str(row.user_id) if row.user_id is not None else None,
- search_space_id=row.search_space_id,
+ workspace_id=row.workspace_id,
tool_name=row.tool_name,
args=row.args,
result_id=row.result_id,
diff --git a/surfsense_backend/app/routes/agent_permissions_route.py b/surfsense_backend/app/routes/agent_permissions_route.py
index 1eb8b1a37..d9136100d 100644
--- a/surfsense_backend/app/routes/agent_permissions_route.py
+++ b/surfsense_backend/app/routes/agent_permissions_route.py
@@ -3,12 +3,12 @@
Surfaces the permission rules consumed by
:class:`PermissionMiddleware`. Rules are scoped at one of three levels:
-* **Search-space wide** — both ``user_id`` and ``thread_id`` are NULL.
+* **Workspace wide** — both ``user_id`` and ``thread_id`` are NULL.
* **Per-user** — ``user_id`` set, ``thread_id`` NULL.
* **Per-thread** — ``thread_id`` set (``user_id`` typically NULL).
The middleware reads these rows at agent build time (see
-``chat_deepagent.py``). UI lets a search-space owner curate them so
+``chat_deepagent.py``). UI lets a workspace owner curate them so
the agent can ask for approval / auto-deny / auto-allow specific
tool patterns.
@@ -36,7 +36,7 @@ from app.db import (
AgentPermissionRule,
NewChatThread,
Permission,
- SearchSpace,
+ Workspace,
get_async_session,
)
from app.users import get_auth_context
@@ -58,7 +58,7 @@ _PERMISSION_PATTERN = re.compile(r"^[a-zA-Z0-9_:.\-*]+$")
class AgentPermissionRuleRead(BaseModel):
id: int
- search_space_id: int
+ workspace_id: int
user_id: str | None
thread_id: int | None
permission: str
@@ -122,7 +122,7 @@ def _validate_permission_string(value: str) -> str:
def _to_read(row: AgentPermissionRule) -> AgentPermissionRuleRead:
return AgentPermissionRuleRead(
id=row.id,
- search_space_id=row.search_space_id,
+ workspace_id=row.workspace_id,
user_id=str(row.user_id) if row.user_id is not None else None,
thread_id=row.thread_id,
permission=row.permission,
@@ -132,17 +132,17 @@ def _to_read(row: AgentPermissionRule) -> AgentPermissionRuleRead:
)
-async def _ensure_search_space_membership_admin(
- session: AsyncSession, auth: AuthContext, search_space_id: int
+async def _ensure_workspace_membership_admin(
+ session: AsyncSession, auth: AuthContext, workspace_id: int
) -> None:
"""Curating agent rules == "settings" administration on the space."""
- space = await session.get(SearchSpace, search_space_id)
+ space = await session.get(Workspace, workspace_id)
if space is None:
- raise HTTPException(status_code=404, detail="Search space not found.")
+ raise HTTPException(status_code=404, detail="Workspace not found.")
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.SETTINGS_UPDATE.value,
"You don't have permission to manage agent permission rules in this space.",
)
@@ -154,21 +154,21 @@ async def _ensure_search_space_membership_admin(
@router.get(
- "/searchspaces/{search_space_id}/agent/permissions/rules",
+ "/workspaces/{workspace_id}/agent/permissions/rules",
response_model=list[AgentPermissionRuleRead],
)
async def list_rules(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> list[AgentPermissionRuleRead]:
user = auth.user
_flag_guard()
- await _ensure_search_space_membership_admin(session, user, search_space_id)
+ await _ensure_workspace_membership_admin(session, user, workspace_id)
stmt = (
select(AgentPermissionRule)
- .where(AgentPermissionRule.search_space_id == search_space_id)
+ .where(AgentPermissionRule.workspace_id == workspace_id)
.order_by(AgentPermissionRule.created_at.desc(), AgentPermissionRule.id.desc())
)
rows = (await session.execute(stmt)).scalars().all()
@@ -176,33 +176,33 @@ async def list_rules(
@router.post(
- "/searchspaces/{search_space_id}/agent/permissions/rules",
+ "/workspaces/{workspace_id}/agent/permissions/rules",
response_model=AgentPermissionRuleRead,
status_code=201,
)
async def create_rule(
- search_space_id: int,
+ workspace_id: int,
payload: AgentPermissionRuleCreate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> AgentPermissionRuleRead:
user = auth.user
_flag_guard()
- await _ensure_search_space_membership_admin(session, user, search_space_id)
+ await _ensure_workspace_membership_admin(session, user, workspace_id)
permission = _validate_permission_string(payload.permission.strip())
pattern = payload.pattern.strip() or "*"
if payload.thread_id is not None:
thread = await session.get(NewChatThread, payload.thread_id)
- if thread is None or thread.search_space_id != search_space_id:
+ if thread is None or thread.workspace_id != workspace_id:
raise HTTPException(
status_code=404,
- detail="Thread not found in this search space.",
+ detail="Thread not found in this workspace.",
)
row = AgentPermissionRule(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=payload.user_id,
thread_id=payload.thread_id,
permission=permission,
@@ -226,11 +226,11 @@ async def create_rule(
@router.patch(
- "/searchspaces/{search_space_id}/agent/permissions/rules/{rule_id}",
+ "/workspaces/{workspace_id}/agent/permissions/rules/{rule_id}",
response_model=AgentPermissionRuleRead,
)
async def update_rule(
- search_space_id: int,
+ workspace_id: int,
rule_id: int,
payload: AgentPermissionRuleUpdate,
session: AsyncSession = Depends(get_async_session),
@@ -238,10 +238,10 @@ async def update_rule(
) -> AgentPermissionRuleRead:
user = auth.user
_flag_guard()
- await _ensure_search_space_membership_admin(session, user, search_space_id)
+ await _ensure_workspace_membership_admin(session, user, workspace_id)
row = await session.get(AgentPermissionRule, rule_id)
- if row is None or row.search_space_id != search_space_id:
+ if row is None or row.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail="Rule not found.")
if payload.pattern is not None:
@@ -262,21 +262,21 @@ async def update_rule(
@router.delete(
- "/searchspaces/{search_space_id}/agent/permissions/rules/{rule_id}",
+ "/workspaces/{workspace_id}/agent/permissions/rules/{rule_id}",
status_code=204,
)
async def delete_rule(
- search_space_id: int,
+ workspace_id: int,
rule_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
) -> None:
user = auth.user
_flag_guard()
- await _ensure_search_space_membership_admin(session, user, search_space_id)
+ await _ensure_workspace_membership_admin(session, user, workspace_id)
row = await session.get(AgentPermissionRule, rule_id)
- if row is None or row.search_space_id != search_space_id:
+ if row is None or row.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail="Rule not found.")
await session.delete(row)
diff --git a/surfsense_backend/app/routes/airtable_add_connector_route.py b/surfsense_backend/app/routes/airtable_add_connector_route.py
index d5cbc2498..c34c7d2fb 100644
--- a/surfsense_backend/app/routes/airtable_add_connector_route.py
+++ b/surfsense_backend/app/routes/airtable_add_connector_route.py
@@ -86,7 +86,7 @@ async def connect_airtable(
Initiate Airtable OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -317,7 +317,7 @@ async def airtable_callback(
connector_type=SearchSourceConnectorType.AIRTABLE_CONNECTOR,
is_indexable=False,
config=credentials_dict,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
diff --git a/surfsense_backend/app/routes/anonymous_chat_routes.py b/surfsense_backend/app/routes/anonymous_chat_routes.py
index f6f984c20..3ff2fd38a 100644
--- a/surfsense_backend/app/routes/anonymous_chat_routes.py
+++ b/surfsense_backend/app/routes/anonymous_chat_routes.py
@@ -337,11 +337,6 @@ async def stream_anonymous_chat(
await TokenQuotaService.anon_release(session_key, ip_key, request_id)
raise HTTPException(status_code=500, detail="Failed to create LLM instance")
- # Server-side tool allow-list enforcement
- anon_allowed_tools = {"web_search"}
- client_disabled = set(body.disabled_tools) if body.disabled_tools else set()
- enabled_for_agent = anon_allowed_tools - client_disabled
-
except HTTPException:
await TokenQuotaService.anon_release_stream_slot(client_ip)
raise
@@ -369,15 +364,14 @@ async def stream_anonymous_chat(
# Load the optional uploaded document as read-only context.
anon_doc = await _load_anon_document(session_id)
- # Minimal Q/A agent: web_search only (when enabled), no
- # filesystem / persistence / subagents. The uploaded document
- # is injected into the system prompt as read-only context.
+ # Minimal Q/A agent: no tools, no filesystem / persistence /
+ # subagents. The uploaded document is injected into the system
+ # prompt as read-only context.
agent = await create_anonymous_chat_agent(
llm=llm,
checkpointer=checkpointer,
anon_session_id=session_id,
anon_doc=anon_doc,
- enable_web_search="web_search" in enabled_for_agent,
)
langchain_messages = []
diff --git a/surfsense_backend/app/routes/chat_comments_routes.py b/surfsense_backend/app/routes/chat_comments_routes.py
index 2e1eb1d27..528fbaf38 100644
--- a/surfsense_backend/app/routes/chat_comments_routes.py
+++ b/surfsense_backend/app/routes/chat_comments_routes.py
@@ -101,9 +101,9 @@ async def remove_comment(
@router.get("/mentions", response_model=MentionListResponse)
async def list_mentions(
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(require_session_context),
):
"""List mentions for the current user."""
- return await get_user_mentions(session, auth, search_space_id)
+ return await get_user_mentions(session, auth, workspace_id)
diff --git a/surfsense_backend/app/routes/circleback_webhook_route.py b/surfsense_backend/app/routes/circleback_webhook_route.py
index 4a5823645..11ddc3f95 100644
--- a/surfsense_backend/app/routes/circleback_webhook_route.py
+++ b/surfsense_backend/app/routes/circleback_webhook_route.py
@@ -2,7 +2,7 @@
Circleback Webhook Route
This module provides a webhook endpoint for receiving meeting data from Circleback.
-It processes the incoming webhook payload and saves it as a document in the specified search space.
+It processes the incoming webhook payload and saves it as a document in the specified workspace.
"""
import logging
@@ -212,9 +212,9 @@ def format_circleback_meeting_to_markdown(payload: CirclebackWebhookPayload) ->
return "\n".join(lines)
-@router.post("/webhooks/circleback/{search_space_id}")
+@router.post("/webhooks/circleback/{workspace_id}")
async def receive_circleback_webhook(
- search_space_id: int,
+ workspace_id: int,
payload: CirclebackWebhookPayload,
session: AsyncSession = Depends(get_async_session),
):
@@ -222,11 +222,11 @@ async def receive_circleback_webhook(
Receive and process a Circleback webhook.
This endpoint receives meeting data from Circleback and saves it as a document
- in the specified search space. The meeting data is converted to Markdown format
+ in the specified workspace. The meeting data is converted to Markdown format
and processed asynchronously.
Args:
- search_space_id: The ID of the search space to save the document to
+ workspace_id: The ID of the workspace to save the document to
payload: The Circleback webhook payload containing meeting data
session: Database session for looking up the connector
@@ -239,13 +239,13 @@ async def receive_circleback_webhook(
"""
try:
logger.info(
- f"Received Circleback webhook for meeting {payload.id} in search space {search_space_id}"
+ f"Received Circleback webhook for meeting {payload.id} in workspace {workspace_id}"
)
- # Look up the Circleback connector for this search space
+ # Look up the Circleback connector for this workspace
connector_result = await session.execute(
select(SearchSourceConnector.id).where(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CIRCLEBACK_CONNECTOR,
)
@@ -254,11 +254,11 @@ async def receive_circleback_webhook(
if connector_id:
logger.info(
- f"Found Circleback connector {connector_id} for search space {search_space_id}"
+ f"Found Circleback connector {connector_id} for workspace {workspace_id}"
)
else:
logger.warning(
- f"No Circleback connector found for search space {search_space_id}. "
+ f"No Circleback connector found for workspace {workspace_id}. "
"Document will be created without connector_id."
)
@@ -289,19 +289,19 @@ async def receive_circleback_webhook(
meeting_name=payload.name,
markdown_content=markdown_content,
metadata=meeting_metadata,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
)
logger.info(
- f"Queued Circleback meeting {payload.id} for processing in search space {search_space_id}"
+ f"Queued Circleback meeting {payload.id} for processing in workspace {workspace_id}"
)
return {
"status": "accepted",
"message": f"Meeting '{payload.name}' queued for processing",
"meeting_id": payload.id,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
}
except Exception as e:
@@ -312,9 +312,9 @@ async def receive_circleback_webhook(
) from e
-@router.get("/webhooks/circleback/{search_space_id}/info")
+@router.get("/webhooks/circleback/{workspace_id}/info")
async def get_circleback_webhook_info(
- search_space_id: int,
+ workspace_id: int,
):
"""
Get information about the Circleback webhook endpoint.
@@ -323,7 +323,7 @@ async def get_circleback_webhook_info(
webhook integration.
Args:
- search_space_id: The ID of the search space
+ workspace_id: The ID of the workspace
Returns:
Webhook configuration information
@@ -332,11 +332,11 @@ async def get_circleback_webhook_info(
# Construct the webhook URL
base_url = getattr(config, "API_BASE_URL", "http://localhost:8000")
- webhook_url = f"{base_url}/api/v1/webhooks/circleback/{search_space_id}"
+ webhook_url = f"{base_url}/api/v1/webhooks/circleback/{workspace_id}"
return {
"webhook_url": webhook_url,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"method": "POST",
"content_type": "application/json",
"description": "Use this URL in your Circleback automation to send meeting data to SurfSense",
diff --git a/surfsense_backend/app/routes/clickup_add_connector_route.py b/surfsense_backend/app/routes/clickup_add_connector_route.py
index 3be32b217..e6e23d57f 100644
--- a/surfsense_backend/app/routes/clickup_add_connector_route.py
+++ b/surfsense_backend/app/routes/clickup_add_connector_route.py
@@ -69,7 +69,7 @@ async def connect_clickup(
Initiate ClickUp OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -290,10 +290,10 @@ async def clickup_callback(
"_token_encrypted": True,
}
- # Check if connector already exists for this search space and user
+ # Check if connector already exists for this workspace and user
existing_connector_result = await session.execute(
select(SearchSourceConnector).filter(
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CLICKUP_CONNECTOR,
@@ -316,7 +316,7 @@ async def clickup_callback(
connector_type=SearchSourceConnectorType.CLICKUP_CONNECTOR,
is_indexable=False,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
diff --git a/surfsense_backend/app/routes/composio_routes.py b/surfsense_backend/app/routes/composio_routes.py
index 410f90256..bdbbe5d38 100644
--- a/surfsense_backend/app/routes/composio_routes.py
+++ b/surfsense_backend/app/routes/composio_routes.py
@@ -41,7 +41,7 @@ from app.utils.connector_naming import (
get_base_name_for_type,
)
from app.utils.oauth_security import OAuthStateManager
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@@ -108,7 +108,7 @@ async def initiate_composio_auth(
Initiate Composio OAuth flow for a specific toolkit.
Query params:
- space_id: Search space ID to add connector to
+ space_id: Workspace ID to add connector to
toolkit_id: Composio toolkit ID (e.g., "googledrive", "gmail", "googlecalendar")
Returns:
@@ -334,7 +334,7 @@ async def composio_callback(
existing_connector_result = await session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.connector_type == connector_type,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.name == connector_name,
)
@@ -395,7 +395,7 @@ async def composio_callback(
name=connector_name,
connector_type=connector_type,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
is_indexable=toolkit_id in INDEXABLE_TOOLKITS,
)
@@ -461,7 +461,7 @@ async def reauth_composio_connector(
after the user completes the OAuth flow again.
Query params:
- space_id: Search space ID the connector belongs to
+ space_id: Workspace ID the connector belongs to
connector_id: ID of the existing Composio connector to re-authenticate
return_url: Optional frontend path to redirect to after completion
"""
@@ -481,7 +481,7 @@ async def reauth_composio_connector(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type.in_(COMPOSIO_CONNECTOR_TYPES),
)
)
@@ -594,7 +594,7 @@ async def composio_reauth_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
)
)
connector = result.scalars().first()
@@ -683,7 +683,7 @@ async def list_composio_drive_folders(
detail="Composio Google Drive connector not found or access denied",
)
- await check_search_space_access(session, auth, connector.search_space_id)
+ await check_workspace_access(session, auth, connector.workspace_id)
composio_connected_account_id = connector.config.get(
"composio_connected_account_id"
diff --git a/surfsense_backend/app/routes/confluence_add_connector_route.py b/surfsense_backend/app/routes/confluence_add_connector_route.py
index cc9e681bf..d6aaaf268 100644
--- a/surfsense_backend/app/routes/confluence_add_connector_route.py
+++ b/surfsense_backend/app/routes/confluence_add_connector_route.py
@@ -85,7 +85,7 @@ async def connect_confluence(
Initiate Confluence OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -309,7 +309,7 @@ async def confluence_callback(
sa_select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
)
@@ -375,7 +375,7 @@ async def confluence_callback(
connector_type=SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
is_indexable=True,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@@ -437,7 +437,7 @@ async def reauth_confluence(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
)
diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py
index 1da0987b0..e29617f93 100644
--- a/surfsense_backend/app/routes/discord_add_connector_route.py
+++ b/surfsense_backend/app/routes/discord_add_connector_route.py
@@ -30,7 +30,8 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
+from app.utils.validators import raise_if_connector_deprecated
logger = logging.getLogger(__name__)
@@ -86,7 +87,7 @@ async def connect_discord(
Initiate Discord OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -94,6 +95,8 @@ async def connect_discord(
"""
user = auth.user
try:
+ raise_if_connector_deprecated(SearchSourceConnectorType.DISCORD_CONNECTOR)
+
if not space_id:
raise HTTPException(status_code=400, detail="space_id is required")
@@ -333,7 +336,7 @@ async def discord_callback(
connector_type=SearchSourceConnectorType.DISCORD_CONNECTOR,
is_indexable=False,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@@ -652,7 +655,7 @@ async def get_discord_channels(
detail="Discord connector not found or access denied",
)
- await check_search_space_access(session, auth, connector.search_space_id)
+ await check_workspace_access(session, auth, connector.workspace_id)
# Get credentials and decrypt bot token
credentials = DiscordAuthCredentialsBase.from_dict(connector.config)
diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index accf3b18f..a5d31f07c 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -16,8 +16,8 @@ from app.db import (
DocumentVersion,
Folder,
Permission,
- SearchSpace,
- SearchSpaceMembership,
+ Workspace,
+ WorkspaceMembership,
get_async_session,
)
from app.schemas import (
@@ -72,9 +72,9 @@ async def create_documents(
await check_permission(
session,
auth,
- request.search_space_id,
+ request.workspace_id,
Permission.DOCUMENTS_CREATE.value,
- "You don't have permission to create documents in this search space",
+ "You don't have permission to create documents in this workspace",
)
if request.document_type == DocumentType.EXTENSION:
@@ -96,14 +96,7 @@ async def create_documents(
"pageContent": individual_document.pageContent,
}
process_extension_document_task.delay(
- document_dict, request.search_space_id, str(user.id)
- )
- elif request.document_type == DocumentType.YOUTUBE_VIDEO:
- from app.tasks.celery_tasks.document_tasks import process_youtube_video_task
-
- for url in request.content:
- process_youtube_video_task.delay(
- url, request.search_space_id, str(user.id)
+ document_dict, request.workspace_id, str(user.id)
)
else:
raise HTTPException(status_code=400, detail="Invalid document type")
@@ -125,7 +118,7 @@ async def create_documents(
@router.post("/documents/fileupload")
async def create_documents_file_upload(
files: list[UploadFile],
- search_space_id: int = Form(...),
+ workspace_id: int = Form(...),
use_vision_llm: bool = Form(False),
processing_mode: str = Form("basic"),
session: AsyncSession = Depends(get_async_session),
@@ -162,9 +155,9 @@ async def create_documents_file_upload(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_CREATE.value,
- "You don't have permission to create documents in this search space",
+ "You don't have permission to create documents in this workspace",
)
if not files:
@@ -216,7 +209,7 @@ async def create_documents_file_upload(
for temp_path, filename, file_size, content_type in saved_files:
try:
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.FILE, filename, search_space_id
+ DocumentType.FILE, filename, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -244,7 +237,7 @@ async def create_documents_file_upload(
continue
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=filename if filename != "unknown" else "Uploaded File",
document_type=DocumentType.FILE,
document_metadata={
@@ -288,7 +281,7 @@ async def create_documents_file_upload(
await store_document_file(
session,
document_id=document.id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
data=original_bytes,
filename=filename,
mime_type=content_type,
@@ -308,7 +301,7 @@ async def create_documents_file_upload(
document_id=document.id,
temp_path=temp_path,
filename=filename,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=str(user.id),
use_vision_llm=use_vision_llm,
processing_mode=validated_mode.value,
@@ -336,7 +329,7 @@ async def read_documents(
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
document_types: str | None = None,
folder_id: int | str | None = None,
sort_by: str = "created_at",
@@ -347,13 +340,13 @@ async def read_documents(
user = auth.user
"""
List documents the user has access to, with optional filtering and pagination.
- Requires DOCUMENTS_READ permission for the search space(s).
+ Requires DOCUMENTS_READ permission for the workspace(s).
Args:
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'.
page: Zero-based page index used when 'skip' is not provided.
page_size: Number of items per page (default: 50). Use -1 to return all remaining items after the offset.
- search_space_id: If provided, restrict results to a specific search space.
+ workspace_id: If provided, restrict results to a specific workspace.
document_types: Comma-separated list of document types to filter by (e.g., "EXTENSION,FILE,SLACK_CONNECTOR").
session: Database session (injected).
user: Current authenticated user (injected).
@@ -363,45 +356,45 @@ async def read_documents(
Notes:
- If both 'skip' and 'page' are provided, 'skip' is used.
- - Results are scoped to documents in search spaces the user has membership in.
+ - Results are scoped to documents in workspaces the user has membership in.
"""
try:
from sqlalchemy import func
- # If specific search_space_id, check permission
- if search_space_id is not None:
+ # If specific workspace_id, check permission
+ if workspace_id is not None:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
query = (
select(Document)
.options(selectinload(Document.created_by))
- .filter(Document.search_space_id == search_space_id)
+ .filter(Document.workspace_id == workspace_id)
)
count_query = (
select(func.count())
.select_from(Document)
- .filter(Document.search_space_id == search_space_id)
+ .filter(Document.workspace_id == workspace_id)
)
else:
- # Get documents from all search spaces user has membership in
+ # Get documents from all workspaces user has membership in
query = (
select(Document)
.options(selectinload(Document.created_by))
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
)
count_query = (
select(func.count())
.select_from(Document)
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
)
# Filter by document_types if provided
@@ -483,7 +476,7 @@ async def read_documents(
unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
updated_at=doc.updated_at,
- search_space_id=doc.search_space_id,
+ workspace_id=doc.workspace_id,
folder_id=doc.folder_id,
created_by_id=doc.created_by_id,
created_by_name=created_by_name,
@@ -519,22 +512,22 @@ async def search_documents(
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
document_types: str | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
- Search documents by title substring, optionally filtered by search_space_id and document_types.
- Requires DOCUMENTS_READ permission for the search space(s).
+ Search documents by title substring, optionally filtered by workspace_id and document_types.
+ Requires DOCUMENTS_READ permission for the workspace(s).
Args:
title: Case-insensitive substring to match against document titles. Required.
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'. Default: None.
page: Zero-based page index used when 'skip' is not provided. Default: None.
page_size: Number of items per page. Use -1 to return all remaining items after the offset. Default: 50.
- search_space_id: Filter results to a specific search space. Default: None.
+ workspace_id: Filter results to a specific workspace. Default: None.
document_types: Comma-separated list of document types to filter by (e.g., "EXTENSION,FILE,SLACK_CONNECTOR").
session: Database session (injected).
user: Current authenticated user (injected).
@@ -549,40 +542,40 @@ async def search_documents(
try:
from sqlalchemy import func
- # If specific search_space_id, check permission
- if search_space_id is not None:
+ # If specific workspace_id, check permission
+ if workspace_id is not None:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
query = (
select(Document)
.options(selectinload(Document.created_by))
- .filter(Document.search_space_id == search_space_id)
+ .filter(Document.workspace_id == workspace_id)
)
count_query = (
select(func.count())
.select_from(Document)
- .filter(Document.search_space_id == search_space_id)
+ .filter(Document.workspace_id == workspace_id)
)
else:
- # Get documents from all search spaces user has membership in
+ # Get documents from all workspaces user has membership in
query = (
select(Document)
.options(selectinload(Document.created_by))
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
)
count_query = (
select(func.count())
.select_from(Document)
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
)
# Only search by title (case-insensitive)
@@ -644,7 +637,7 @@ async def search_documents(
unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
updated_at=doc.updated_at,
- search_space_id=doc.search_space_id,
+ workspace_id=doc.workspace_id,
folder_id=doc.folder_id,
created_by_id=doc.created_by_id,
created_by_name=created_by_name,
@@ -674,9 +667,105 @@ async def search_documents(
) from e
+class SemanticSearchRequest(PydanticBaseModel):
+ """Request body for hybrid (semantic + keyword) knowledge-base search."""
+
+ workspace_id: int
+ query: str = Field(min_length=1)
+ top_k: int = Field(default=5, ge=1, le=20)
+ document_types: list[str] | None = Field(
+ default=None,
+ description="Optional DocumentType names to restrict the search to.",
+ )
+
+
+class SemanticSearchChunk(PydanticBaseModel):
+ content: str
+ position: int
+ score: float
+
+
+class SemanticSearchHit(PydanticBaseModel):
+ document_id: int
+ title: str
+ document_type: str | None = None
+ score: float
+ chunks: list[SemanticSearchChunk]
+
+
+class SemanticSearchResponse(PydanticBaseModel):
+ items: list[SemanticSearchHit]
+
+
+@router.post("/documents/search-semantic", response_model=SemanticSearchResponse)
+async def search_documents_semantic(
+ request: SemanticSearchRequest,
+ session: AsyncSession = Depends(get_async_session),
+ auth: AuthContext = Depends(get_auth_context),
+):
+ """Hybrid semantic + keyword search over a workspace's knowledge base.
+
+ Thin REST door onto the same retriever the chat agent uses: returns the most
+ relevant documents with their matching passages, ranked by relevance.
+ Requires DOCUMENTS_READ permission for the workspace.
+ """
+ # Local import: the retriever pulls in the embedding model + agent stack,
+ # so keep it out of module import (mirrors the celery-task imports here).
+ from app.agents.chat.multi_agent_chat.shared.retrieval.hybrid_search import (
+ search_chunks,
+ )
+ from app.agents.chat.multi_agent_chat.shared.retrieval.models import SearchScope
+
+ await check_permission(
+ session,
+ auth,
+ request.workspace_id,
+ Permission.DOCUMENTS_READ.value,
+ "You don't have permission to read documents in this workspace",
+ )
+
+ scope = SearchScope(
+ document_types=tuple(request.document_types)
+ if request.document_types
+ else None,
+ )
+ try:
+ hits = await search_chunks(
+ session,
+ workspace_id=request.workspace_id,
+ query=request.query,
+ scope=scope,
+ top_k=request.top_k,
+ )
+ except Exception as e:
+ raise HTTPException(
+ status_code=500, detail=f"Semantic search failed: {e!s}"
+ ) from e
+
+ return SemanticSearchResponse(
+ items=[
+ SemanticSearchHit(
+ document_id=hit.document_id,
+ title=hit.title,
+ document_type=hit.document_type,
+ score=hit.score,
+ chunks=[
+ SemanticSearchChunk(
+ content=chunk.content,
+ position=chunk.position,
+ score=chunk.score,
+ )
+ for chunk in hit.chunks
+ ],
+ )
+ for hit in hits
+ ]
+ )
+
+
@router.get("/documents/search/titles", response_model=DocumentTitleSearchResponse)
async def search_document_titles(
- search_space_id: int,
+ workspace_id: int,
title: str = "",
page: int = 0,
page_size: int = 20,
@@ -691,7 +780,7 @@ async def search_document_titles(
Results are ordered by relevance using trigram similarity scores.
Args:
- search_space_id: The search space to search in. Required.
+ workspace_id: The workspace to search in. Required.
title: Search query (case-insensitive). If empty or < 2 chars, returns recent documents.
page: Zero-based page index. Default: 0.
page_size: Number of items per page. Default: 20.
@@ -704,13 +793,13 @@ async def search_document_titles(
from sqlalchemy import desc, func, or_
try:
- # Check permission for the search space
+ # Check permission for the workspace
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
# Base query - only select lightweight fields
@@ -718,7 +807,7 @@ async def search_document_titles(
Document.id,
Document.title,
Document.document_type,
- ).filter(Document.search_space_id == search_space_id)
+ ).filter(Document.workspace_id == workspace_id)
# If query is too short, return recent documents ordered by updated_at
if len(title.strip()) < 2:
@@ -782,7 +871,7 @@ async def search_document_titles(
@router.get("/documents/by-virtual-path", response_model=DocumentTitleRead)
async def get_document_by_virtual_path(
- search_space_id: int,
+ workspace_id: int,
virtual_path: str,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -809,14 +898,14 @@ async def get_document_by_virtual_path(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
document = await virtual_path_to_doc(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
virtual_path=virtual_path,
)
if document is None:
@@ -839,13 +928,13 @@ async def get_document_by_virtual_path(
@router.get("/documents/status", response_model=DocumentStatusBatchResponse)
async def get_documents_status(
- search_space_id: int,
+ workspace_id: int,
document_ids: str,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- Batch status endpoint for documents in a search space.
+ Batch status endpoint for documents in a workspace.
Returns lightweight status info for the provided document IDs, intended for
polling async ETL progress in chat upload flows.
@@ -854,9 +943,9 @@ async def get_documents_status(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
# Parse comma-separated IDs (e.g. "1,2,3")
@@ -878,7 +967,7 @@ async def get_documents_status(
result = await session.execute(
select(Document).filter(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.id.in_(parsed_ids),
)
)
@@ -907,17 +996,17 @@ async def get_documents_status(
@router.get("/documents/type-counts")
async def get_document_type_counts(
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
- Get counts of documents by type for search spaces the user has access to.
- Requires DOCUMENTS_READ permission for the search space(s).
+ Get counts of documents by type for workspaces the user has access to.
+ Requires DOCUMENTS_READ permission for the workspace(s).
Args:
- search_space_id: If provided, restrict counts to a specific search space.
+ workspace_id: If provided, restrict counts to a specific workspace.
session: Database session (injected).
user: Current authenticated user (injected).
@@ -927,27 +1016,27 @@ async def get_document_type_counts(
try:
from sqlalchemy import func
- if search_space_id is not None:
- # Check permission for specific search space
+ if workspace_id is not None:
+ # Check permission for specific workspace
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
query = (
select(Document.document_type, func.count(Document.id))
- .filter(Document.search_space_id == search_space_id)
+ .filter(Document.workspace_id == workspace_id)
.group_by(Document.document_type)
)
else:
- # Get counts from all search spaces user has membership in
+ # Get counts from all workspaces user has membership in
query = (
select(Document.document_type, func.count(Document.id))
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
.group_by(Document.document_type)
)
@@ -1001,9 +1090,9 @@ async def get_document_by_chunk_id(
await check_permission(
session,
auth,
- document.search_space_id,
+ document.workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
total_result = await session.execute(
@@ -1048,7 +1137,7 @@ async def get_document_by_chunk_id(
unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
updated_at=document.updated_at,
- search_space_id=document.search_space_id,
+ workspace_id=document.workspace_id,
chunks=windowed_chunks,
total_chunks=total_chunks,
chunk_start_index=start,
@@ -1063,7 +1152,7 @@ async def get_document_by_chunk_id(
@router.get("/documents/watched-folders", response_model=list[FolderRead])
async def get_watched_folders(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
@@ -1071,16 +1160,16 @@ async def get_watched_folders(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
folders = (
(
await session.execute(
select(Folder).where(
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
Folder.parent_id.is_(None),
Folder.folder_metadata.isnot(None),
Folder.folder_metadata["watched"].astext == "true",
@@ -1126,9 +1215,9 @@ async def get_document_chunks_paginated(
await check_permission(
session,
auth,
- document.search_space_id,
+ document.workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
total_result = await session.execute(
@@ -1171,7 +1260,7 @@ async def read_document(
):
"""
Get a specific document by ID.
- Requires DOCUMENTS_READ permission for the search space.
+ Requires DOCUMENTS_READ permission for the workspace.
"""
try:
result = await session.execute(
@@ -1184,13 +1273,13 @@ async def read_document(
status_code=404, detail=f"Document with id {document_id} not found"
)
- # Check permission for the search space
+ # Check permission for the workspace
await check_permission(
session,
auth,
- document.search_space_id,
+ document.workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
raw_content = document.content or ""
@@ -1205,7 +1294,7 @@ async def read_document(
unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
updated_at=document.updated_at,
- search_space_id=document.search_space_id,
+ workspace_id=document.workspace_id,
folder_id=document.folder_id,
)
except HTTPException:
@@ -1225,7 +1314,7 @@ async def update_document(
):
"""
Update a document.
- Requires DOCUMENTS_UPDATE permission for the search space.
+ Requires DOCUMENTS_UPDATE permission for the workspace.
"""
try:
result = await session.execute(
@@ -1238,13 +1327,13 @@ async def update_document(
status_code=404, detail=f"Document with id {document_id} not found"
)
- # Check permission for the search space
+ # Check permission for the workspace
await check_permission(
session,
auth,
- db_document.search_space_id,
+ db_document.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
- "You don't have permission to update documents in this search space",
+ "You don't have permission to update documents in this workspace",
)
update_data = document_update.model_dump(exclude_unset=True)
@@ -1264,7 +1353,7 @@ async def update_document(
unique_identifier_hash=db_document.unique_identifier_hash,
created_at=db_document.created_at,
updated_at=db_document.updated_at,
- search_space_id=db_document.search_space_id,
+ workspace_id=db_document.workspace_id,
folder_id=db_document.folder_id,
)
except HTTPException:
@@ -1284,7 +1373,7 @@ async def delete_document(
):
"""
Delete a document.
- Requires DOCUMENTS_DELETE permission for the search space.
+ Requires DOCUMENTS_DELETE permission for the workspace.
Documents in "processing" state cannot be deleted.
Heavy cascade deletion runs asynchronously via Celery so the API
@@ -1313,13 +1402,13 @@ async def delete_document(
detail="Document is already being deleted.",
)
- # Check permission for the search space
+ # Check permission for the workspace
await check_permission(
session,
auth,
- document.search_space_id,
+ document.workspace_id,
Permission.DOCUMENTS_DELETE.value,
- "You don't have permission to delete documents in this search space",
+ "You don't have permission to delete documents in this workspace",
)
# Mark the document as "deleting" so it's excluded from searches,
@@ -1371,7 +1460,7 @@ async def list_document_versions(
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
- session, user, document.search_space_id, Permission.DOCUMENTS_READ.value
+ session, user, document.workspace_id, Permission.DOCUMENTS_READ.value
)
versions = (
@@ -1413,7 +1502,7 @@ async def get_document_version(
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
- session, user, document.search_space_id, Permission.DOCUMENTS_READ.value
+ session, user, document.workspace_id, Permission.DOCUMENTS_READ.value
)
version = (
@@ -1452,7 +1541,7 @@ async def restore_document_version(
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
- session, user, document.search_space_id, Permission.DOCUMENTS_UPDATE.value
+ session, user, document.workspace_id, Permission.DOCUMENTS_UPDATE.value
)
version = (
@@ -1503,20 +1592,20 @@ _MAX_MTIME_CHECK_FILES = 10_000
class FolderMtimeCheckRequest(PydanticBaseModel):
folder_name: str
- search_space_id: int
+ workspace_id: int
files: list[FolderMtimeCheckFile] = Field(max_length=_MAX_MTIME_CHECK_FILES)
class FolderUnlinkRequest(PydanticBaseModel):
folder_name: str
- search_space_id: int
+ workspace_id: int
root_folder_id: int | None = None
relative_paths: list[str]
class FolderSyncFinalizeRequest(PydanticBaseModel):
folder_name: str
- search_space_id: int
+ workspace_id: int
root_folder_id: int | None = None
all_relative_paths: list[str]
@@ -1537,16 +1626,16 @@ async def folder_mtime_check(
await check_permission(
session,
auth,
- request.search_space_id,
+ request.workspace_id,
Permission.DOCUMENTS_CREATE.value,
- "You don't have permission to create documents in this search space",
+ "You don't have permission to create documents in this workspace",
)
uid_hashes = {}
for f in request.files:
uid = f"{request.folder_name}:{f.relative_path}"
uid_hash = compute_identifier_hash(
- DocumentType.LOCAL_FOLDER_FILE.value, uid, request.search_space_id
+ DocumentType.LOCAL_FOLDER_FILE.value, uid, request.workspace_id
)
uid_hashes[uid_hash] = f
@@ -1589,7 +1678,7 @@ async def folder_mtime_check(
async def folder_upload(
files: list[UploadFile],
folder_name: str = Form(...),
- search_space_id: int = Form(...),
+ workspace_id: int = Form(...),
relative_paths: str = Form(...),
root_folder_id: int | None = Form(None),
use_vision_llm: bool = Form(False),
@@ -1613,9 +1702,9 @@ async def folder_upload(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_CREATE.value,
- "You don't have permission to create documents in this search space",
+ "You don't have permission to create documents in this workspace",
)
if not files:
@@ -1655,9 +1744,9 @@ async def folder_upload(
if root_folder_id:
root_folder = await session.get(Folder, root_folder_id)
- if not root_folder or root_folder.search_space_id != search_space_id:
+ if not root_folder or root_folder.workspace_id != workspace_id:
raise HTTPException(
- status_code=404, detail="Root folder not found in this search space"
+ status_code=404, detail="Root folder not found in this workspace"
)
if not root_folder_id:
@@ -1671,7 +1760,7 @@ async def folder_upload(
select(Folder).where(
Folder.name == folder_name,
Folder.parent_id.is_(None),
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
)
)
).scalar_one_or_none()
@@ -1682,7 +1771,7 @@ async def folder_upload(
else:
root_folder = Folder(
name=folder_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=str(user.id),
position="a0",
folder_metadata=watched_metadata,
@@ -1721,7 +1810,7 @@ async def folder_upload(
)
index_uploaded_folder_files_task.delay(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=str(user.id),
folder_name=folder_name,
root_folder_id=root_folder_id,
@@ -1756,9 +1845,9 @@ async def folder_unlink(
await check_permission(
session,
auth,
- request.search_space_id,
+ request.workspace_id,
Permission.DOCUMENTS_DELETE.value,
- "You don't have permission to delete documents in this search space",
+ "You don't have permission to delete documents in this workspace",
)
deleted_count = 0
@@ -1768,7 +1857,7 @@ async def folder_unlink(
uid_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value,
unique_id,
- request.search_space_id,
+ request.workspace_id,
)
existing = (
@@ -1813,9 +1902,9 @@ async def folder_sync_finalize(
await check_permission(
session,
auth,
- request.search_space_id,
+ request.workspace_id,
Permission.DOCUMENTS_DELETE.value,
- "You don't have permission to delete documents in this search space",
+ "You don't have permission to delete documents in this workspace",
)
if not request.root_folder_id:
@@ -1829,7 +1918,7 @@ async def folder_sync_finalize(
uid_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value,
unique_id,
- request.search_space_id,
+ request.workspace_id,
)
seen_hashes.add(uid_hash)
@@ -1838,7 +1927,7 @@ async def folder_sync_finalize(
await session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == request.search_space_id,
+ Document.workspace_id == request.workspace_id,
Document.folder_id.in_(subtree_ids),
)
)
@@ -1866,7 +1955,7 @@ async def folder_sync_finalize(
await _cleanup_empty_folders(
session,
request.root_folder_id,
- request.search_space_id,
+ request.workspace_id,
existing_dirs,
folder_mapping,
subtree_ids=subtree_ids,
diff --git a/surfsense_backend/app/routes/dropbox_add_connector_route.py b/surfsense_backend/app/routes/dropbox_add_connector_route.py
index 6a9284371..d8b6d2072 100644
--- a/surfsense_backend/app/routes/dropbox_add_connector_route.py
+++ b/surfsense_backend/app/routes/dropbox_add_connector_route.py
@@ -36,7 +36,7 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -124,7 +124,7 @@ async def reauth_dropbox(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.DROPBOX_CONNECTOR,
)
@@ -304,7 +304,7 @@ async def dropbox_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.DROPBOX_CONNECTOR,
)
@@ -372,7 +372,7 @@ async def dropbox_callback(
connector_type=SearchSourceConnectorType.DROPBOX_CONNECTOR,
is_indexable=True,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
@@ -431,7 +431,7 @@ async def list_dropbox_folders(
status_code=404, detail="Dropbox connector not found or access denied"
)
- await check_search_space_access(session, auth, connector.search_space_id)
+ await check_workspace_access(session, auth, connector.workspace_id)
dropbox_client = DropboxClient(session, connector_id)
items, error = await list_folder_contents(dropbox_client, path=parent_path)
diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py
index 0bc1dd45f..c0776b7fc 100644
--- a/surfsense_backend/app/routes/editor_routes.py
+++ b/surfsense_backend/app/routes/editor_routes.py
@@ -43,9 +43,9 @@ EDITOR_PLATE_MAX_BYTES = 1 * 1024 * 1024
EDITOR_PLATE_MAX_LINES = 5000
-@router.get("/search-spaces/{search_space_id}/documents/{document_id}/editor-content")
+@router.get("/workspaces/{workspace_id}/documents/{document_id}/editor-content")
async def get_editor_content(
- search_space_id: int,
+ workspace_id: int,
document_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -62,15 +62,15 @@ async def get_editor_content(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
result = await session.execute(
select(Document).filter(
Document.id == document_id,
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
)
)
document = result.scalars().first()
@@ -172,11 +172,9 @@ async def get_editor_content(
return _build_response(markdown_content)
-@router.get(
- "/search-spaces/{search_space_id}/documents/{document_id}/download-markdown"
-)
+@router.get("/workspaces/{workspace_id}/documents/{document_id}/download-markdown")
async def download_document_markdown(
- search_space_id: int,
+ workspace_id: int,
document_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -188,15 +186,15 @@ async def download_document_markdown(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
result = await session.execute(
select(Document).filter(
Document.id == document_id,
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
)
)
document = result.scalars().first()
@@ -239,9 +237,9 @@ async def download_document_markdown(
)
-@router.post("/search-spaces/{search_space_id}/documents/{document_id}/save")
+@router.post("/workspaces/{workspace_id}/documents/{document_id}/save")
async def save_document(
- search_space_id: int,
+ workspace_id: int,
document_id: int,
data: dict[str, Any],
session: AsyncSession = Depends(get_async_session),
@@ -262,15 +260,15 @@ async def save_document(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_UPDATE.value,
- "You don't have permission to update documents in this search space",
+ "You don't have permission to update documents in this workspace",
)
result = await session.execute(
select(Document).filter(
Document.id == document_id,
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
)
)
document = result.scalars().first()
@@ -324,9 +322,9 @@ async def save_document(
}
-@router.get("/search-spaces/{search_space_id}/documents/{document_id}/export")
+@router.get("/workspaces/{workspace_id}/documents/{document_id}/export")
async def export_document(
- search_space_id: int,
+ workspace_id: int,
document_id: int,
format: ExportFormat = Query(
ExportFormat.PDF,
@@ -339,15 +337,15 @@ async def export_document(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read documents in this search space",
+ "You don't have permission to read documents in this workspace",
)
result = await session.execute(
select(Document).filter(
Document.id == document_id,
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
)
)
document = result.scalars().first()
diff --git a/surfsense_backend/app/routes/export_routes.py b/surfsense_backend/app/routes/export_routes.py
index 70df33b2e..19d4d301e 100644
--- a/surfsense_backend/app/routes/export_routes.py
+++ b/surfsense_backend/app/routes/export_routes.py
@@ -18,9 +18,9 @@ logger = logging.getLogger(__name__)
router = APIRouter()
-@router.get("/search-spaces/{search_space_id}/export")
+@router.get("/workspaces/{workspace_id}/export")
async def export_knowledge_base(
- search_space_id: int,
+ workspace_id: int,
folder_id: int | None = Query(
None, description="Export only this folder's subtree"
),
@@ -31,13 +31,13 @@ async def export_knowledge_base(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to export documents in this search space",
+ "You don't have permission to export documents in this workspace",
)
try:
- result = await build_export_zip(session, search_space_id, folder_id)
+ result = await build_export_zip(session, workspace_id, folder_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
diff --git a/surfsense_backend/app/routes/folders_routes.py b/surfsense_backend/app/routes/folders_routes.py
index 1da0c9b0e..3e238b7e5 100644
--- a/surfsense_backend/app/routes/folders_routes.py
+++ b/surfsense_backend/app/routes/folders_routes.py
@@ -42,32 +42,32 @@ async def create_folder(
await check_permission(
session,
auth,
- request.search_space_id,
+ request.workspace_id,
Permission.DOCUMENTS_CREATE.value,
- "You don't have permission to create folders in this search space",
+ "You don't have permission to create folders in this workspace",
)
if request.parent_id is not None:
parent = await session.get(Folder, request.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Parent folder not found")
- if parent.search_space_id != request.search_space_id:
+ if parent.workspace_id != request.workspace_id:
raise HTTPException(
status_code=400,
- detail="Parent folder belongs to a different search space",
+ detail="Parent folder belongs to a different workspace",
)
await validate_folder_depth(session, request.parent_id)
position = await generate_folder_position(
- session, request.search_space_id, request.parent_id
+ session, request.workspace_id, request.parent_id
)
folder = Folder(
name=request.name,
position=position,
parent_id=request.parent_id,
- search_space_id=request.search_space_id,
+ workspace_id=request.workspace_id,
created_by_id=user.id,
)
session.add(folder)
@@ -91,23 +91,23 @@ async def create_folder(
@router.get("/folders", response_model=list[FolderRead])
async def list_folders(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
- """List all folders in a search space (flat). Requires DOCUMENTS_READ permission."""
+ """List all folders in a workspace (flat). Requires DOCUMENTS_READ permission."""
try:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read folders in this search space",
+ "You don't have permission to read folders in this workspace",
)
result = await session.execute(
select(Folder)
- .where(Folder.search_space_id == search_space_id)
+ .where(Folder.workspace_id == workspace_id)
.order_by(Folder.position)
)
return result.scalars().all()
@@ -135,9 +135,9 @@ async def get_folder(
await check_permission(
session,
auth,
- folder.search_space_id,
+ folder.workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read folders in this search space",
+ "You don't have permission to read folders in this workspace",
)
return folder
@@ -165,9 +165,9 @@ async def get_folder_breadcrumb(
await check_permission(
session,
auth,
- folder.search_space_id,
+ folder.workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read folders in this search space",
+ "You don't have permission to read folders in this workspace",
)
result = await session.execute(
@@ -208,9 +208,9 @@ async def stop_watching_folder(
await check_permission(
session,
auth,
- folder.search_space_id,
+ folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
- "You don't have permission to update folders in this search space",
+ "You don't have permission to update folders in this workspace",
)
if folder.folder_metadata and isinstance(folder.folder_metadata, dict):
@@ -237,9 +237,9 @@ async def update_folder(
await check_permission(
session,
auth,
- folder.search_space_id,
+ folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
- "You don't have permission to update folders in this search space",
+ "You don't have permission to update folders in this workspace",
)
folder.name = request.name
@@ -277,9 +277,9 @@ async def move_folder(
await check_permission(
session,
auth,
- folder.search_space_id,
+ folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
- "You don't have permission to move folders in this search space",
+ "You don't have permission to move folders in this workspace",
)
if request.new_parent_id is not None:
@@ -288,10 +288,10 @@ async def move_folder(
raise HTTPException(
status_code=404, detail="Target parent folder not found"
)
- if new_parent.search_space_id != folder.search_space_id:
+ if new_parent.workspace_id != folder.workspace_id:
raise HTTPException(
status_code=400,
- detail="Cannot move folder to a different search space",
+ detail="Cannot move folder to a different workspace",
)
await check_no_circular_reference(session, folder_id, request.new_parent_id)
@@ -299,7 +299,7 @@ async def move_folder(
await validate_folder_depth(session, request.new_parent_id, subtree_depth)
position = await generate_folder_position(
- session, folder.search_space_id, request.new_parent_id
+ session, folder.workspace_id, request.new_parent_id
)
folder.parent_id = request.new_parent_id
folder.position = position
@@ -337,14 +337,14 @@ async def reorder_folder(
await check_permission(
session,
auth,
- folder.search_space_id,
+ folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
- "You don't have permission to reorder folders in this search space",
+ "You don't have permission to reorder folders in this workspace",
)
position = await generate_folder_position(
session,
- folder.search_space_id,
+ folder.workspace_id,
folder.parent_id,
before_position=request.before_position,
after_position=request.after_position,
@@ -378,9 +378,9 @@ async def delete_folder(
await check_permission(
session,
auth,
- folder.search_space_id,
+ folder.workspace_id,
Permission.DOCUMENTS_DELETE.value,
- "You don't have permission to delete folders in this search space",
+ "You don't have permission to delete folders in this workspace",
)
subtree_ids = await get_folder_subtree_ids(session, folder_id)
@@ -455,19 +455,19 @@ async def move_document(
await check_permission(
session,
auth,
- document.search_space_id,
+ document.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
- "You don't have permission to move documents in this search space",
+ "You don't have permission to move documents in this workspace",
)
if request.folder_id is not None:
target = await session.get(Folder, request.folder_id)
if not target:
raise HTTPException(status_code=404, detail="Target folder not found")
- if target.search_space_id != document.search_space_id:
+ if target.workspace_id != document.workspace_id:
raise HTTPException(
status_code=400,
- detail="Cannot move document to a folder in a different search space",
+ detail="Cannot move document to a folder in a different workspace",
)
document.folder_id = request.folder_id
@@ -502,14 +502,14 @@ async def bulk_move_documents(
if not documents:
raise HTTPException(status_code=404, detail="No documents found")
- search_space_ids = {doc.search_space_id for doc in documents}
- for ss_id in search_space_ids:
+ workspace_ids = {doc.workspace_id for doc in documents}
+ for ss_id in workspace_ids:
await check_permission(
session,
auth,
ss_id,
Permission.DOCUMENTS_UPDATE.value,
- "You don't have permission to move documents in this search space",
+ "You don't have permission to move documents in this workspace",
)
if request.folder_id is not None:
@@ -517,14 +517,12 @@ async def bulk_move_documents(
if not target:
raise HTTPException(status_code=404, detail="Target folder not found")
mismatched = [
- doc.id
- for doc in documents
- if doc.search_space_id != target.search_space_id
+ doc.id for doc in documents if doc.workspace_id != target.workspace_id
]
if mismatched:
raise HTTPException(
status_code=400,
- detail="Cannot move documents to a folder in a different search space",
+ detail="Cannot move documents to a folder in a different workspace",
)
for doc in documents:
diff --git a/surfsense_backend/app/routes/gateway_webhook_routes.py b/surfsense_backend/app/routes/gateway_webhook_routes.py
index 931794059..a3c0609ce 100644
--- a/surfsense_backend/app/routes/gateway_webhook_routes.py
+++ b/surfsense_backend/app/routes/gateway_webhook_routes.py
@@ -53,7 +53,7 @@ from app.observability.metrics import (
)
from app.users import get_auth_context
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
router = APIRouter(prefix="/gateway", tags=["gateway"])
config_router = APIRouter(prefix="/gateway", tags=["gateway"])
@@ -170,7 +170,7 @@ def _slack_event_kind(payload: dict[str, Any]) -> str:
class StartBindingRequest(BaseModel):
platform: ExternalChatPlatform = ExternalChatPlatform.TELEGRAM
- search_space_id: int
+ workspace_id: int
class StartBindingResponse(BaseModel):
@@ -180,12 +180,12 @@ class StartBindingResponse(BaseModel):
expires_at: datetime
-class UpdateBindingSearchSpaceRequest(BaseModel):
- search_space_id: int
+class UpdateBindingWorkspaceRequest(BaseModel):
+ workspace_id: int
-class UpdateAccountSearchSpaceRequest(BaseModel):
- search_space_id: int
+class UpdateAccountWorkspaceRequest(BaseModel):
+ workspace_id: int
def _active_whatsapp_account_mode() -> ExternalChatAccountMode | None:
@@ -249,7 +249,7 @@ def _telegram_message(payload: dict[str, Any]) -> dict[str, Any] | None:
@router.get("/slack/install")
async def install_slack_gateway(
- search_space_id: int,
+ workspace_id: int,
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, str]:
@@ -258,8 +258,8 @@ async def install_slack_gateway(
raise HTTPException(
status_code=500, detail="Slack gateway OAuth is not configured"
)
- await check_search_space_access(session, auth, search_space_id)
- state = _get_state_manager().generate_secure_state(search_space_id, user.id)
+ await check_workspace_access(session, auth, workspace_id)
+ state = _get_state_manager().generate_secure_state(workspace_id, user.id)
auth_params = {
"client_id": config.GATEWAY_SLACK_CLIENT_ID,
"scope": ",".join(SLACK_BOT_SCOPES),
@@ -382,7 +382,7 @@ async def slack_gateway_callback(
ExternalChatBinding(
account_id=account.id,
user_id=user_id,
- search_space_id=space_id,
+ workspace_id=space_id,
state=ExternalChatBindingState.BOUND,
external_peer_id=peer_id,
external_peer_kind=ExternalChatPeerKind.DIRECT,
@@ -395,7 +395,7 @@ async def slack_gateway_callback(
)
)
elif binding.user_id == user_id:
- binding.search_space_id = space_id
+ binding.workspace_id = space_id
binding.external_metadata = {
**(binding.external_metadata or {}),
"kind": "slack_user",
@@ -409,7 +409,7 @@ async def slack_gateway_callback(
@router.get("/discord/install")
async def install_discord_gateway(
- search_space_id: int,
+ workspace_id: int,
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, str]:
@@ -418,8 +418,8 @@ async def install_discord_gateway(
raise HTTPException(
status_code=500, detail="Discord gateway OAuth is not configured"
)
- await check_search_space_access(session, auth, search_space_id)
- state = _get_state_manager().generate_secure_state(search_space_id, user.id)
+ await check_workspace_access(session, auth, workspace_id)
+ state = _get_state_manager().generate_secure_state(workspace_id, user.id)
auth_params = {
"client_id": config.DISCORD_CLIENT_ID,
"scope": " ".join(DISCORD_GATEWAY_SCOPES),
@@ -559,7 +559,7 @@ async def discord_gateway_callback(
ExternalChatBinding(
account_id=account.id,
user_id=user_id,
- search_space_id=space_id,
+ workspace_id=space_id,
state=ExternalChatBindingState.BOUND,
external_peer_id=peer_id,
external_peer_kind=ExternalChatPeerKind.DIRECT,
@@ -568,7 +568,7 @@ async def discord_gateway_callback(
)
)
elif binding.user_id == user_id:
- binding.search_space_id = space_id
+ binding.workspace_id = space_id
binding.external_username = discord_username or binding.external_username
binding.external_metadata = {
**(binding.external_metadata or {}),
@@ -718,7 +718,7 @@ async def start_binding(
session: AsyncSession = Depends(get_async_session),
) -> StartBindingResponse:
user = auth.user
- await check_search_space_access(session, auth, body.search_space_id)
+ await check_workspace_access(session, auth, body.workspace_id)
code = generate_pairing_code()
if body.platform == ExternalChatPlatform.TELEGRAM:
if not _telegram_gateway_enabled():
@@ -758,7 +758,7 @@ async def start_binding(
binding = ExternalChatBinding(
account_id=account.id,
user_id=user.id,
- search_space_id=body.search_space_id,
+ workspace_id=body.workspace_id,
state=ExternalChatBindingState.PENDING,
pairing_code=code,
pairing_code_expires_at=expires_at,
@@ -794,7 +794,7 @@ async def list_bindings(
"id": binding.id,
"platform": account.platform.value,
"state": binding.state.value,
- "search_space_id": binding.search_space_id,
+ "workspace_id": binding.workspace_id,
"external_display_name": binding.external_display_name,
"external_username": binding.external_username,
"external_metadata": binding.external_metadata,
@@ -869,7 +869,7 @@ async def list_connections(
workspace_id = None
route_type = "binding"
connection_id = binding.id
- search_space_id = binding.search_space_id
+ workspace_id = binding.workspace_id
display_name = binding.external_display_name or binding.external_username
if account.platform == ExternalChatPlatform.SLACK:
workspace_name = account_state.get("team_name")
@@ -886,9 +886,7 @@ async def list_connections(
baileys_account_ids.add(int(account.id))
route_type = "account"
connection_id = account.id
- search_space_id = (
- account.owner_search_space_id or binding.search_space_id
- )
+ workspace_id = account.owner_workspace_id or binding.workspace_id
display_name = "WhatsApp Bridge"
connections.append(
@@ -899,7 +897,7 @@ async def list_connections(
"platform": account.platform.value,
"mode": account.mode.value,
"state": binding.state.value,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"display_name": display_name or workspace_name,
"external_username": (
None
@@ -907,7 +905,6 @@ async def list_connections(
else binding.external_username
),
"workspace_name": workspace_name,
- "workspace_id": workspace_id,
"health_status": account.health_status.value,
"suspended_reason": binding.suspended_reason,
}
@@ -921,7 +918,7 @@ async def list_connections(
ExternalChatAccount.owner_user_id == user.id,
ExternalChatAccount.platform == ExternalChatPlatform.WHATSAPP,
ExternalChatAccount.mode == ExternalChatAccountMode.SELF_HOST_BYO,
- ExternalChatAccount.owner_search_space_id.is_not(None),
+ ExternalChatAccount.owner_workspace_id.is_not(None),
)
)
for account in account_result.scalars():
@@ -936,11 +933,10 @@ async def list_connections(
"platform": account.platform.value,
"mode": account.mode.value,
"state": "bound",
- "search_space_id": account.owner_search_space_id,
+ "workspace_id": account.owner_workspace_id,
"display_name": "WhatsApp Bridge",
"external_username": None,
"workspace_name": account_state.get("display_phone_number"),
- "workspace_id": account_state.get("phone_number_id"),
"health_status": account.health_status.value,
"suspended_reason": account.suspended_reason,
}
@@ -995,10 +991,10 @@ async def get_gateway_config(
}
-@router.patch("/bindings/{binding_id}/search-space")
-async def update_binding_search_space(
+@router.patch("/bindings/{binding_id}/workspace")
+async def update_binding_workspace(
binding_id: int,
- body: UpdateBindingSearchSpaceRequest,
+ body: UpdateBindingWorkspaceRequest,
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
@@ -1017,19 +1013,19 @@ async def update_binding_search_space(
if account is None or _is_inactive_whatsapp_account(account):
raise HTTPException(status_code=404, detail="Binding not found")
- await check_search_space_access(session, auth, body.search_space_id)
- if binding.search_space_id != body.search_space_id:
- binding.search_space_id = body.search_space_id
+ await check_workspace_access(session, auth, body.workspace_id)
+ if binding.workspace_id != body.workspace_id:
+ binding.workspace_id = body.workspace_id
binding.new_chat_thread_id = None
binding.updated_at = datetime.now(UTC)
await session.commit()
return {"ok": True}
-@router.patch("/accounts/{account_id}/search-space")
-async def update_gateway_account_search_space(
+@router.patch("/accounts/{account_id}/workspace")
+async def update_gateway_account_workspace(
account_id: int,
- body: UpdateAccountSearchSpaceRequest,
+ body: UpdateAccountWorkspaceRequest,
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
@@ -1044,8 +1040,8 @@ async def update_gateway_account_search_space(
):
raise HTTPException(status_code=404, detail="Gateway account not found")
- await check_search_space_access(session, auth, body.search_space_id)
- account.owner_search_space_id = body.search_space_id
+ await check_workspace_access(session, auth, body.workspace_id)
+ account.owner_workspace_id = body.workspace_id
account.updated_at = datetime.now(UTC)
result = await session.execute(
@@ -1058,7 +1054,7 @@ async def update_gateway_account_search_space(
)
)
for binding in result.scalars():
- binding.search_space_id = body.search_space_id
+ binding.workspace_id = body.workspace_id
binding.new_chat_thread_id = None
binding.updated_at = datetime.now(UTC)
@@ -1113,7 +1109,7 @@ async def delete_gateway_account(
for binding in result.scalars():
revoke_binding(binding)
- account.owner_search_space_id = None
+ account.owner_workspace_id = None
account.suspended_at = datetime.now(UTC)
account.suspended_reason = "disconnected"
account.updated_at = datetime.now(UTC)
diff --git a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
index 95c8fe12b..eb2c60d50 100644
--- a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
+++ b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
@@ -22,13 +22,13 @@ from app.db import (
)
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
from app.users import get_auth_context
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
router = APIRouter(prefix="/gateway/whatsapp/baileys", tags=["gateway"])
class BaileysPairRequest(BaseModel):
- search_space_id: int
+ workspace_id: int
phone_number: str
@@ -66,7 +66,7 @@ async def request_pairing_code(
) -> dict[str, Any]:
user = auth.user
_ensure_baileys_enabled()
- await check_search_space_access(session, auth, body.search_space_id)
+ await check_workspace_access(session, auth, body.workspace_id)
adapter = WhatsAppBaileysAdapter()
try:
pairing = await adapter.request_pairing_code(phone_number=body.phone_number)
@@ -79,7 +79,7 @@ async def request_pairing_code(
platform=ExternalChatPlatform.WHATSAPP,
mode=ExternalChatAccountMode.SELF_HOST_BYO,
owner_user_id=user.id,
- owner_search_space_id=body.search_space_id,
+ owner_workspace_id=body.workspace_id,
is_system_account=False,
cursor_state={},
health_status=ExternalChatHealthStatus.UNKNOWN,
@@ -87,7 +87,7 @@ async def request_pairing_code(
session.add(account)
else:
account.mode = ExternalChatAccountMode.SELF_HOST_BYO
- account.owner_search_space_id = body.search_space_id
+ account.owner_workspace_id = body.workspace_id
account.health_status = ExternalChatHealthStatus.UNKNOWN
account.suspended_at = None
account.suspended_reason = None
diff --git a/surfsense_backend/app/routes/google_calendar_add_connector_route.py b/surfsense_backend/app/routes/google_calendar_add_connector_route.py
index 8789287b8..793a255cb 100644
--- a/surfsense_backend/app/routes/google_calendar_add_connector_route.py
+++ b/surfsense_backend/app/routes/google_calendar_add_connector_route.py
@@ -141,7 +141,7 @@ async def reauth_calendar(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
)
@@ -286,7 +286,7 @@ async def calendar_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
)
@@ -343,7 +343,7 @@ async def calendar_callback(
name=connector_name,
connector_type=SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
config=creds_dict,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
is_indexable=False,
)
diff --git a/surfsense_backend/app/routes/google_drive_add_connector_route.py b/surfsense_backend/app/routes/google_drive_add_connector_route.py
index c97c82eb0..a82a41ae1 100644
--- a/surfsense_backend/app/routes/google_drive_add_connector_route.py
+++ b/surfsense_backend/app/routes/google_drive_add_connector_route.py
@@ -46,7 +46,7 @@ from app.utils.oauth_security import (
TokenEncryption,
generate_code_verifier,
)
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
# Relax token scope validation for Google OAuth
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
@@ -119,7 +119,7 @@ async def connect_drive(
Initiate Google Drive OAuth flow.
Query params:
- space_id: Search space ID to add connector to
+ space_id: Workspace ID to add connector to
Returns:
JSON with auth_url to redirect user to Google authorization
@@ -177,7 +177,7 @@ async def reauth_drive(
Initiate Google Drive re-authentication to upgrade OAuth scopes.
Query params:
- space_id: Search space ID the connector belongs to
+ space_id: Workspace ID the connector belongs to
connector_id: ID of the existing connector to re-authenticate
Returns:
@@ -189,7 +189,7 @@ async def reauth_drive(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
)
@@ -349,7 +349,7 @@ async def drive_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
)
@@ -415,7 +415,7 @@ async def drive_callback(
**creds_dict,
"start_page_token": None, # Will be set on first index
},
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
is_indexable=True,
)
@@ -517,7 +517,7 @@ async def list_google_drive_folders(
detail="Google Drive connector not found or access denied",
)
- await check_search_space_access(session, auth, connector.search_space_id)
+ await check_workspace_access(session, auth, connector.workspace_id)
# Initialize Drive client (credentials will be loaded on first API call)
drive_client = GoogleDriveClient(session, connector_id)
diff --git a/surfsense_backend/app/routes/google_gmail_add_connector_route.py b/surfsense_backend/app/routes/google_gmail_add_connector_route.py
index 82475c792..c7b0fef9c 100644
--- a/surfsense_backend/app/routes/google_gmail_add_connector_route.py
+++ b/surfsense_backend/app/routes/google_gmail_add_connector_route.py
@@ -100,7 +100,7 @@ async def connect_gmail(
Initiate Google Gmail OAuth flow.
Query params:
- space_id: Search space ID to add connector to
+ space_id: Workspace ID to add connector to
Returns:
JSON with auth_url to redirect user to Google authorization
@@ -159,7 +159,7 @@ async def reauth_gmail(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
)
@@ -317,7 +317,7 @@ async def gmail_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
)
@@ -374,7 +374,7 @@ async def gmail_callback(
name=connector_name,
connector_type=SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
config=creds_dict,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
is_indexable=False,
)
diff --git a/surfsense_backend/app/routes/image_generation_routes.py b/surfsense_backend/app/routes/image_generation_routes.py
index 96cb3825c..5a6c71127 100644
--- a/surfsense_backend/app/routes/image_generation_routes.py
+++ b/surfsense_backend/app/routes/image_generation_routes.py
@@ -22,8 +22,8 @@ from app.db import (
ImageGeneration,
Model,
Permission,
- SearchSpace,
- SearchSpaceMembership,
+ Workspace,
+ WorkspaceMembership,
get_async_session,
)
from app.schemas import (
@@ -68,7 +68,7 @@ def _get_global_connection(connection_id: int) -> dict | None:
async def _resolve_billing_for_image_gen(
session: AsyncSession,
config_id: int | None,
- search_space: SearchSpace,
+ workspace: Workspace,
) -> tuple[str, str, int]:
"""Resolve ``(billing_tier, base_model, reserve_micros)`` for a request.
@@ -84,18 +84,18 @@ async def _resolve_billing_for_image_gen(
"""
resolved_id = config_id
if resolved_id is None:
- resolved_id = search_space.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
+ resolved_id = workspace.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
if is_image_gen_auto_mode(resolved_id):
candidates = await auto_model_candidates(
session,
- search_space_id=search_space.id,
- user_id=search_space.user_id,
+ workspace_id=workspace.id,
+ user_id=workspace.user_id,
capability="image_gen",
)
if not candidates:
return ("free", "auto", DEFAULT_IMAGE_RESERVE_MICROS)
- selected = choose_auto_model_candidate(candidates, search_space.id)
+ selected = choose_auto_model_candidate(candidates, workspace.id)
resolved_id = int(selected["id"])
if resolved_id < 0:
@@ -119,19 +119,19 @@ async def _resolve_billing_for_image_gen(
async def _execute_image_generation(
session: AsyncSession,
image_gen: ImageGeneration,
- search_space: SearchSpace,
+ workspace: Workspace,
) -> None:
"""
Call litellm.aimage_generation() with the appropriate config.
Resolution order:
1. Explicit image_gen_model_id on the request
- 2. Search space's image_gen_model_id preference
+ 2. Workspace's image_gen_model_id preference
3. Falls back to Auto mode if available
"""
config_id = image_gen.image_gen_model_id
if config_id is None:
- config_id = search_space.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
+ config_id = workspace.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
image_gen.image_gen_model_id = config_id
# Build kwargs
@@ -150,13 +150,13 @@ async def _execute_image_generation(
if is_image_gen_auto_mode(config_id):
candidates = await auto_model_candidates(
session,
- search_space_id=search_space.id,
- user_id=search_space.user_id,
+ workspace_id=workspace.id,
+ user_id=workspace.user_id,
capability="image_gen",
)
if not candidates:
raise ValueError("No image-generation models are available for Auto mode")
- config_id = int(choose_auto_model_candidate(candidates, search_space.id)["id"])
+ config_id = int(choose_auto_model_candidate(candidates, workspace.id)["id"])
image_gen.image_gen_model_id = config_id
if config_id < 0:
@@ -191,9 +191,9 @@ async def _execute_image_generation(
if not db_model or not db_model.connection or not db_model.connection.enabled:
raise ValueError(f"Image generation model {config_id} not found")
conn = db_model.connection
- if conn.search_space_id is not None and conn.search_space_id != search_space.id:
+ if conn.workspace_id is not None and conn.workspace_id != workspace.id:
raise ValueError(f"Image generation model {config_id} not found")
- if conn.user_id is not None and conn.user_id != search_space.user_id:
+ if conn.user_id is not None and conn.user_id != workspace.user_id:
raise ValueError(f"Image generation model {config_id} not found")
if not has_capability(db_model, "image_gen"):
raise ValueError(f"Model {config_id} is not image-generation capable")
@@ -254,7 +254,7 @@ async def create_image_generation(
Premium configs are gated by the user's shared premium credit pool.
The flow is:
- 1. Permission check + load the search space (cheap, no provider call).
+ 1. Permission check + load the workspace (cheap, no provider call).
2. Resolve which config will run so we know its billing tier and the
worst-case reservation size *before* opening any DB rows.
3. Wrap the entire ImageGeneration row insert + provider call in
@@ -273,20 +273,20 @@ async def create_image_generation(
await check_permission(
session,
auth,
- data.search_space_id,
+ data.workspace_id,
Permission.IMAGE_GENERATIONS_CREATE.value,
- "You don't have permission to create image generations in this search space",
+ "You don't have permission to create image generations in this workspace",
)
result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == data.search_space_id)
+ select(Workspace).filter(Workspace.id == data.workspace_id)
)
- search_space = result.scalars().first()
- if not search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
+ workspace = result.scalars().first()
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
billing_tier, base_model, reserve_micros = await _resolve_billing_for_image_gen(
- session, data.image_gen_model_id, search_space
+ session, data.image_gen_model_id, workspace
)
# billable_call runs OUTSIDE the inner try/except so QuotaInsufficientError
@@ -296,8 +296,8 @@ async def create_image_generation(
# exists when none does, and (2) return HTTP 200 to a client
# whose request was actively *denied* (issue K).
async with billable_call(
- user_id=search_space.user_id,
- search_space_id=data.search_space_id,
+ user_id=workspace.user_id,
+ workspace_id=data.workspace_id,
billing_tier=billing_tier,
base_model=base_model,
quota_reserve_micros_override=reserve_micros,
@@ -313,14 +313,14 @@ async def create_image_generation(
style=data.style,
response_format=data.response_format,
image_gen_model_id=data.image_gen_model_id,
- search_space_id=data.search_space_id,
+ workspace_id=data.workspace_id,
created_by_id=user.id,
)
session.add(db_image_gen)
await session.flush()
try:
- await _execute_image_generation(session, db_image_gen, search_space)
+ await _execute_image_generation(session, db_image_gen, workspace)
except Exception as e:
logger.exception("Image generation call failed")
db_image_gen.error_message = str(e)
@@ -363,7 +363,7 @@ async def create_image_generation(
@router.get("/image-generations", response_model=list[ImageGenerationListRead])
async def list_image_generations(
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
skip: int = 0,
limit: int = 50,
session: AsyncSession = Depends(get_async_session),
@@ -377,17 +377,17 @@ async def list_image_generations(
limit = 100
try:
- if search_space_id is not None:
+ if workspace_id is not None:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.IMAGE_GENERATIONS_READ.value,
- "You don't have permission to read image generations in this search space",
+ "You don't have permission to read image generations in this workspace",
)
result = await session.execute(
select(ImageGeneration)
- .filter(ImageGeneration.search_space_id == search_space_id)
+ .filter(ImageGeneration.workspace_id == workspace_id)
.order_by(ImageGeneration.created_at.desc())
.offset(skip)
.limit(limit)
@@ -395,9 +395,9 @@ async def list_image_generations(
else:
result = await session.execute(
select(ImageGeneration)
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
.order_by(ImageGeneration.created_at.desc())
.offset(skip)
.limit(limit)
@@ -434,9 +434,9 @@ async def get_image_generation(
await check_permission(
session,
auth,
- image_gen.search_space_id,
+ image_gen.workspace_id,
Permission.IMAGE_GENERATIONS_READ.value,
- "You don't have permission to read image generations in this search space",
+ "You don't have permission to read image generations in this workspace",
)
return image_gen
@@ -466,9 +466,9 @@ async def delete_image_generation(
await check_permission(
session,
auth,
- db_image_gen.search_space_id,
+ db_image_gen.workspace_id,
Permission.IMAGE_GENERATIONS_DELETE.value,
- "You don't have permission to delete image generations in this search space",
+ "You don't have permission to delete image generations in this workspace",
)
await session.delete(db_image_gen)
@@ -500,8 +500,8 @@ async def serve_generated_image(
Serve a generated image by ID, protected by a signed token.
The token is generated when the image URL is created by the generate_image
- tool and encodes the image_gen_id, search_space_id, and an expiry timestamp.
- This ensures only users with access to the search space can view images,
+ tool and encodes the image_gen_id, workspace_id, and an expiry timestamp.
+ This ensures only users with access to the workspace can view images,
without requiring auth headers (which tags cannot pass).
Args:
diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py
index c29d0609b..c82363daa 100644
--- a/surfsense_backend/app/routes/jira_add_connector_route.py
+++ b/surfsense_backend/app/routes/jira_add_connector_route.py
@@ -83,7 +83,7 @@ async def connect_jira(
Initiate Jira OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -326,7 +326,7 @@ async def jira_callback(
sa_select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)
@@ -392,7 +392,7 @@ async def jira_callback(
connector_type=SearchSourceConnectorType.JIRA_CONNECTOR,
is_indexable=False,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@@ -454,7 +454,7 @@ async def reauth_jira(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)
diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py
index 1d7cc172f..f29d8fcae 100644
--- a/surfsense_backend/app/routes/linear_add_connector_route.py
+++ b/surfsense_backend/app/routes/linear_add_connector_route.py
@@ -87,7 +87,7 @@ async def connect_linear(
Initiate Linear OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -148,7 +148,7 @@ async def reauth_linear(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
@@ -346,7 +346,7 @@ async def linear_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
)
@@ -406,7 +406,7 @@ async def linear_callback(
connector_type=SearchSourceConnectorType.LINEAR_CONNECTOR,
is_indexable=False,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
diff --git a/surfsense_backend/app/routes/logs_routes.py b/surfsense_backend/app/routes/logs_routes.py
index 28c3e4fd1..c4903327d 100644
--- a/surfsense_backend/app/routes/logs_routes.py
+++ b/surfsense_backend/app/routes/logs_routes.py
@@ -11,8 +11,8 @@ from app.db import (
LogLevel,
LogStatus,
Permission,
- SearchSpace,
- SearchSpaceMembership,
+ Workspace,
+ WorkspaceMembership,
get_async_session,
)
from app.schemas import LogCreate, LogRead, LogUpdate
@@ -33,13 +33,13 @@ async def create_log(
Note: This is typically called internally. Requires LOGS_READ permission (since logs are usually system-generated).
"""
try:
- # Check if the user has access to the search space
+ # Check if the user has access to the workspace
await check_permission(
session,
auth,
- log.search_space_id,
+ log.workspace_id,
Permission.LOGS_READ.value,
- "You don't have permission to access logs in this search space",
+ "You don't have permission to access logs in this workspace",
)
db_log = Log(**log.model_dump())
@@ -60,7 +60,7 @@ async def create_log(
async def read_logs(
skip: int = 0,
limit: int = 100,
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
level: LogLevel | None = None,
status: LogStatus | None = None,
source: str | None = None,
@@ -72,34 +72,34 @@ async def read_logs(
user = auth.user
"""
Get logs with optional filtering.
- Requires LOGS_READ permission for the search space(s).
+ Requires LOGS_READ permission for the workspace(s).
"""
try:
# Apply filters
filters = []
- if search_space_id is not None:
- # Check permission for specific search space
+ if workspace_id is not None:
+ # Check permission for specific workspace
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.LOGS_READ.value,
- "You don't have permission to read logs in this search space",
+ "You don't have permission to read logs in this workspace",
)
- # Build query for specific search space
+ # Build query for specific workspace
query = (
select(Log)
- .filter(Log.search_space_id == search_space_id)
+ .filter(Log.workspace_id == workspace_id)
.order_by(desc(Log.created_at))
)
else:
- # Build base query - logs from search spaces user has membership in
+ # Build base query - logs from workspaces user has membership in
query = (
select(Log)
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
.order_by(desc(Log.created_at))
)
@@ -141,7 +141,7 @@ async def read_log(
):
"""
Get a specific log by ID.
- Requires LOGS_READ permission for the search space.
+ Requires LOGS_READ permission for the workspace.
"""
try:
result = await session.execute(select(Log).filter(Log.id == log_id))
@@ -150,13 +150,13 @@ async def read_log(
if not log:
raise HTTPException(status_code=404, detail="Log not found")
- # Check permission for the search space
+ # Check permission for the workspace
await check_permission(
session,
auth,
- log.search_space_id,
+ log.workspace_id,
Permission.LOGS_READ.value,
- "You don't have permission to read logs in this search space",
+ "You don't have permission to read logs in this workspace",
)
return log
@@ -186,13 +186,13 @@ async def update_log(
if not db_log:
raise HTTPException(status_code=404, detail="Log not found")
- # Check permission for the search space
+ # Check permission for the workspace
await check_permission(
session,
auth,
- db_log.search_space_id,
+ db_log.workspace_id,
Permission.LOGS_READ.value,
- "You don't have permission to access logs in this search space",
+ "You don't have permission to access logs in this workspace",
)
# Update only provided fields
@@ -220,7 +220,7 @@ async def delete_log(
):
"""
Delete a log entry.
- Requires LOGS_DELETE permission for the search space.
+ Requires LOGS_DELETE permission for the workspace.
"""
try:
result = await session.execute(select(Log).filter(Log.id == log_id))
@@ -229,13 +229,13 @@ async def delete_log(
if not db_log:
raise HTTPException(status_code=404, detail="Log not found")
- # Check permission for the search space
+ # Check permission for the workspace
await check_permission(
session,
auth,
- db_log.search_space_id,
+ db_log.workspace_id,
Permission.LOGS_DELETE.value,
- "You don't have permission to delete logs in this search space",
+ "You don't have permission to delete logs in this workspace",
)
await session.delete(db_log)
@@ -250,25 +250,25 @@ async def delete_log(
) from e
-@router.get("/logs/search-space/{search_space_id}/summary")
+@router.get("/logs/workspaces/{workspace_id}/summary")
async def get_logs_summary(
- search_space_id: int,
+ workspace_id: int,
hours: int = 24,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- Get a summary of logs for a search space in the last X hours.
- Requires LOGS_READ permission for the search space.
+ Get a summary of logs for a workspace in the last X hours.
+ Requires LOGS_READ permission for the workspace.
"""
try:
# Check permission
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.LOGS_READ.value,
- "You don't have permission to read logs in this search space",
+ "You don't have permission to read logs in this workspace",
)
# Calculate time window
@@ -277,9 +277,7 @@ async def get_logs_summary(
# Get logs from the time window
result = await session.execute(
select(Log)
- .filter(
- and_(Log.search_space_id == search_space_id, Log.created_at >= since)
- )
+ .filter(and_(Log.workspace_id == workspace_id, Log.created_at >= since))
.order_by(desc(Log.created_at))
)
logs = result.scalars().all()
diff --git a/surfsense_backend/app/routes/luma_add_connector_route.py b/surfsense_backend/app/routes/luma_add_connector_route.py
index 9a6f18940..103cfd793 100644
--- a/surfsense_backend/app/routes/luma_add_connector_route.py
+++ b/surfsense_backend/app/routes/luma_add_connector_route.py
@@ -13,6 +13,7 @@ from app.db import (
get_async_session,
)
from app.users import require_session_context
+from app.utils.validators import raise_if_connector_deprecated
logger = logging.getLogger(__name__)
@@ -23,7 +24,7 @@ class AddLumaConnectorRequest(BaseModel):
"""Request model for adding a Luma connector."""
api_key: str = Field(..., description="Luma API key")
- space_id: int = Field(..., description="Search space ID")
+ space_id: int = Field(..., description="Workspace ID")
@router.post("/connectors/luma/add")
@@ -48,10 +49,12 @@ async def add_luma_connector(
"""
user = auth.user
try:
- # Check if a Luma connector already exists for this search space and user
+ raise_if_connector_deprecated(SearchSourceConnectorType.LUMA_CONNECTOR)
+
+ # Check if a Luma connector already exists for this workspace and user
result = await session.execute(
select(SearchSourceConnector).filter(
- SearchSourceConnector.search_space_id == request.space_id,
+ SearchSourceConnector.workspace_id == request.space_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
@@ -81,7 +84,7 @@ async def add_luma_connector(
name="Luma Event Connector",
connector_type=SearchSourceConnectorType.LUMA_CONNECTOR,
config={"api_key": request.api_key},
- search_space_id=request.space_id,
+ workspace_id=request.space_id,
user_id=user.id,
is_indexable=False,
)
@@ -123,10 +126,10 @@ async def delete_luma_connector(
session: AsyncSession = Depends(get_async_session),
):
"""
- Delete the Luma connector for the authenticated user in a specific search space.
+ Delete the Luma connector for the authenticated user in a specific workspace.
Args:
- space_id: Search space ID
+ space_id: Workspace ID
user: Current authenticated user
session: Database session
@@ -140,7 +143,7 @@ async def delete_luma_connector(
try:
result = await session.execute(
select(SearchSourceConnector).filter(
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
@@ -179,10 +182,10 @@ async def test_luma_connector(
session: AsyncSession = Depends(get_async_session),
):
"""
- Test the Luma connector for the authenticated user in a specific search space.
+ Test the Luma connector for the authenticated user in a specific workspace.
Args:
- space_id: Search space ID
+ space_id: Workspace ID
user: Current authenticated user
session: Database session
@@ -194,10 +197,10 @@ async def test_luma_connector(
"""
user = auth.user
try:
- # Get the Luma connector for this search space and user
+ # Get the Luma connector for this workspace and user
result = await session.execute(
select(SearchSourceConnector).filter(
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
diff --git a/surfsense_backend/app/routes/mcp_oauth_route.py b/surfsense_backend/app/routes/mcp_oauth_route.py
index dbeb8738c..b894b0703 100644
--- a/surfsense_backend/app/routes/mcp_oauth_route.py
+++ b/surfsense_backend/app/routes/mcp_oauth_route.py
@@ -413,7 +413,7 @@ async def mcp_oauth_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type == db_connector_type,
)
)
@@ -468,7 +468,7 @@ async def mcp_oauth_callback(
connector_type=db_connector_type,
is_indexable=False,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@@ -539,7 +539,7 @@ async def reauth_mcp_service(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type == db_connector_type,
)
)
diff --git a/surfsense_backend/app/routes/model_connections_routes.py b/surfsense_backend/app/routes/model_connections_routes.py
index 84e9b830d..8f6a17cfd 100644
--- a/surfsense_backend/app/routes/model_connections_routes.py
+++ b/surfsense_backend/app/routes/model_connections_routes.py
@@ -14,7 +14,7 @@ from app.db import (
ModelSource,
NewChatThread,
Permission,
- SearchSpace,
+ Workspace,
get_async_session,
)
from app.schemas import (
@@ -88,7 +88,7 @@ def _connection_read(
api_key=conn.api_key,
extra=conn.extra or {},
scope=conn.scope,
- search_space_id=conn.search_space_id,
+ workspace_id=conn.workspace_id,
user_id=conn.user_id,
enabled=conn.enabled,
has_api_key=bool(conn.api_key),
@@ -142,7 +142,7 @@ def _default_model_for(models: list[Model], capability: str) -> int | None:
async def _load_role_model(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
model_id: int,
) -> Model | dict | None:
if model_id < 0:
@@ -157,7 +157,7 @@ async def _load_role_model(
.where(Model.id == model_id)
)
model = result.scalars().first()
- if model is None or model.connection.search_space_id != search_space_id:
+ if model is None or model.connection.workspace_id != workspace_id:
return None
return model
@@ -171,14 +171,14 @@ def _role_model_enabled(model: Model | dict) -> bool:
async def _validate_role_model_id(
session: AsyncSession,
*,
- search_space_id: int,
+ workspace_id: int,
model_id: int | None,
capability: str,
) -> int:
if model_id is None or model_id == 0:
return 0
- model = await _load_role_model(session, search_space_id, model_id)
+ model = await _load_role_model(session, workspace_id, model_id)
if model and _role_model_enabled(model) and has_capability(model, capability):
return model_id
@@ -191,14 +191,14 @@ async def _validate_role_model_id(
async def _resolve_role_model_id(
session: AsyncSession,
*,
- search_space_id: int,
+ workspace_id: int,
model_id: int | None,
capability: str,
) -> int:
try:
return await _validate_role_model_id(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
model_id=model_id,
capability=capability,
)
@@ -206,29 +206,27 @@ async def _resolve_role_model_id(
return 0
-async def _clear_invalid_roles(
- session: AsyncSession, search_space_id: int
-) -> SearchSpace:
- search_space = await _get_search_space(session, search_space_id)
- search_space.chat_model_id = await _resolve_role_model_id(
+async def _clear_invalid_roles(session: AsyncSession, workspace_id: int) -> Workspace:
+ workspace = await _get_workspace(session, workspace_id)
+ workspace.chat_model_id = await _resolve_role_model_id(
session,
- search_space_id=search_space_id,
- model_id=search_space.chat_model_id,
+ workspace_id=workspace_id,
+ model_id=workspace.chat_model_id,
capability="chat",
)
- search_space.vision_model_id = await _resolve_role_model_id(
+ workspace.vision_model_id = await _resolve_role_model_id(
session,
- search_space_id=search_space_id,
- model_id=search_space.vision_model_id,
+ workspace_id=workspace_id,
+ model_id=workspace.vision_model_id,
capability="vision",
)
- search_space.image_gen_model_id = await _resolve_role_model_id(
+ workspace.image_gen_model_id = await _resolve_role_model_id(
session,
- search_space_id=search_space_id,
- model_id=search_space.image_gen_model_id,
+ workspace_id=workspace_id,
+ model_id=workspace.image_gen_model_id,
capability="image_gen",
)
- return search_space
+ return workspace
async def _default_unset_roles(
@@ -236,24 +234,24 @@ async def _default_unset_roles(
conn: Connection,
models: list[Model],
) -> None:
- if conn.scope != ConnectionScope.SEARCH_SPACE or conn.search_space_id is None:
+ if conn.scope != ConnectionScope.SEARCH_SPACE or conn.workspace_id is None:
return
- search_space = await _get_search_space(session, conn.search_space_id)
- if search_space.chat_model_id is None:
- search_space.chat_model_id = _default_model_for(models, "chat")
- if search_space.vision_model_id is None:
+ workspace = await _get_workspace(session, conn.workspace_id)
+ if workspace.chat_model_id is None:
+ workspace.chat_model_id = _default_model_for(models, "chat")
+ if workspace.vision_model_id is None:
vision_default = None
- if search_space.chat_model_id:
+ if workspace.chat_model_id:
chat_model = next(
- (m for m in models if m.id == search_space.chat_model_id), None
+ (m for m in models if m.id == workspace.chat_model_id), None
)
if chat_model and has_capability(chat_model, "vision"):
vision_default = chat_model.id
- search_space.vision_model_id = vision_default or _default_model_for(
+ workspace.vision_model_id = vision_default or _default_model_for(
models, "vision"
)
- if search_space.image_gen_model_id is None:
- search_space.image_gen_model_id = _default_model_for(models, "image_gen")
+ if workspace.image_gen_model_id is None:
+ workspace.image_gen_model_id = _default_model_for(models, "image_gen")
@router.get("/model-providers", response_model=list[ModelProviderRead])
@@ -274,14 +272,14 @@ async def list_model_providers(auth: AuthContext = Depends(require_session_conte
]
-async def _get_search_space(session: AsyncSession, search_space_id: int) -> SearchSpace:
+async def _get_workspace(session: AsyncSession, workspace_id: int) -> Workspace:
result = await session.execute(
- select(SearchSpace).where(SearchSpace.id == search_space_id)
+ select(Workspace).where(Workspace.id == workspace_id)
)
- search_space = result.scalars().first()
- if not search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
- return search_space
+ workspace = result.scalars().first()
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
+ return workspace
async def _load_connection(session: AsyncSession, connection_id: int) -> Connection:
@@ -304,13 +302,13 @@ async def _assert_connection_access(
allow_spaceless_pat: bool = False,
) -> None:
user = auth.user
- if conn.search_space_id:
+ if conn.workspace_id:
await check_permission(
session,
auth,
- conn.search_space_id,
+ conn.workspace_id,
permission,
- "You don't have permission to manage model connections in this search space",
+ "You don't have permission to manage model connections in this workspace",
)
return
if conn.user_id != user.id:
@@ -346,21 +344,21 @@ async def list_global_connections(auth: AuthContext = Depends(require_session_co
@router.get("/model-connections", response_model=list[ConnectionRead])
async def list_connections(
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
stmt = select(Connection).options(selectinload(Connection.models))
- if search_space_id is not None:
+ if workspace_id is not None:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
- "You don't have permission to view model connections in this search space",
+ "You don't have permission to view model connections in this workspace",
)
- stmt = stmt.where(Connection.search_space_id == search_space_id)
+ stmt = stmt.where(Connection.workspace_id == workspace_id)
else:
stmt = stmt.where(Connection.user_id == user.id)
result = await session.execute(stmt.order_by(Connection.id))
@@ -379,25 +377,25 @@ async def create_connection(
if data.scope == ConnectionScope.GLOBAL:
raise HTTPException(status_code=400, detail="GLOBAL connections are YAML-only")
if data.scope == ConnectionScope.SEARCH_SPACE:
- if data.search_space_id is None:
- raise HTTPException(status_code=400, detail="search_space_id is required")
+ if data.workspace_id is None:
+ raise HTTPException(status_code=400, detail="workspace_id is required")
await check_permission(
session,
auth,
- data.search_space_id,
+ data.workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
- "You don't have permission to create model connections in this search space",
+ "You don't have permission to create model connections in this workspace",
)
elif auth.is_gated:
raise HTTPException(
status_code=403,
detail="Managing personal model connections requires an interactive session",
)
- payload = data.model_dump(exclude={"search_space_id", "models"})
+ payload = data.model_dump(exclude={"workspace_id", "models"})
conn = Connection(
**payload,
- search_space_id=data.search_space_id
+ workspace_id=data.workspace_id
if data.scope == ConnectionScope.SEARCH_SPACE
else None,
user_id=user.id,
@@ -430,13 +428,13 @@ async def preview_connection_models(
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
- if data.scope == ConnectionScope.SEARCH_SPACE and data.search_space_id is not None:
+ if data.scope == ConnectionScope.SEARCH_SPACE and data.workspace_id is not None:
await check_permission(
session,
auth,
- data.search_space_id,
+ data.workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
- "You don't have permission to create model connections in this search space",
+ "You don't have permission to create model connections in this workspace",
)
elif auth.is_gated:
raise HTTPException(
@@ -451,7 +449,7 @@ async def preview_connection_models(
extra=data.extra or {},
scope=data.scope,
enabled=data.enabled,
- search_space_id=data.search_space_id
+ workspace_id=data.workspace_id
if data.scope == ConnectionScope.SEARCH_SPACE
else None,
user_id=user.id,
@@ -470,13 +468,13 @@ async def test_preview_connection_model(
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
- if data.scope == ConnectionScope.SEARCH_SPACE and data.search_space_id is not None:
+ if data.scope == ConnectionScope.SEARCH_SPACE and data.workspace_id is not None:
await check_permission(
session,
auth,
- data.search_space_id,
+ data.workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
- "You don't have permission to create model connections in this search space",
+ "You don't have permission to create model connections in this workspace",
)
elif auth.is_gated:
raise HTTPException(
@@ -495,7 +493,7 @@ async def test_preview_connection_model(
extra=data.extra or {},
scope=data.scope,
enabled=data.enabled,
- search_space_id=data.search_space_id
+ workspace_id=data.workspace_id
if data.scope == ConnectionScope.SEARCH_SPACE
else None,
user_id=user.id,
@@ -525,12 +523,12 @@ async def update_connection(
await _assert_connection_access(
session, auth, conn, Permission.LLM_CONFIGS_UPDATE.value
)
- search_space_id = conn.search_space_id
+ workspace_id = conn.workspace_id
for key, value in data.model_dump(exclude_unset=True).items():
setattr(conn, key, value)
await session.commit()
- if search_space_id is not None:
- await _clear_invalid_roles(session, search_space_id)
+ if workspace_id is not None:
+ await _clear_invalid_roles(session, workspace_id)
await session.commit()
conn = await _load_connection(session, connection_id)
return _connection_read(conn, list(conn.models))
@@ -546,11 +544,11 @@ async def delete_connection(
await _assert_connection_access(
session, auth, conn, Permission.LLM_CONFIGS_DELETE.value
)
- search_space_id = conn.search_space_id
+ workspace_id = conn.workspace_id
await session.delete(conn)
await session.commit()
- if search_space_id is not None:
- await _clear_invalid_roles(session, search_space_id)
+ if workspace_id is not None:
+ await _clear_invalid_roles(session, workspace_id)
await session.commit()
return {"status": "deleted"}
@@ -611,8 +609,8 @@ async def discover_connection_models(
await session.commit()
conn = await _load_connection(session, connection_id)
await _default_unset_roles(session, conn, list(conn.models))
- if conn.search_space_id is not None:
- await _clear_invalid_roles(session, conn.search_space_id)
+ if conn.workspace_id is not None:
+ await _clear_invalid_roles(session, conn.workspace_id)
await session.commit()
conn = await _load_connection(session, connection_id)
return [_model_read(model) for model in conn.models]
@@ -654,8 +652,8 @@ async def add_manual_model(
await session.refresh(model)
conn = await _load_connection(session, connection_id)
await _default_unset_roles(session, conn, list(conn.models))
- if conn.search_space_id is not None:
- await _clear_invalid_roles(session, conn.search_space_id)
+ if conn.workspace_id is not None:
+ await _clear_invalid_roles(session, conn.workspace_id)
await session.commit()
await session.refresh(model)
return _model_read(model)
@@ -674,7 +672,7 @@ async def bulk_update_models(
await _assert_connection_access(
session, auth, conn, Permission.LLM_CONFIGS_UPDATE.value
)
- search_space_id = conn.search_space_id
+ workspace_id = conn.workspace_id
model_ids = set(data.model_ids)
await session.execute(
@@ -684,8 +682,8 @@ async def bulk_update_models(
)
await session.commit()
session.expire_all()
- if search_space_id is not None:
- await _clear_invalid_roles(session, search_space_id)
+ if workspace_id is not None:
+ await _clear_invalid_roles(session, workspace_id)
await session.commit()
session.expire_all()
@@ -715,14 +713,14 @@ async def update_model(
await _assert_connection_access(
session, auth, model.connection, Permission.LLM_CONFIGS_UPDATE.value
)
- search_space_id = model.connection.search_space_id
+ workspace_id = model.connection.workspace_id
update = data.model_dump(exclude_unset=True)
for key, value in update.items():
setattr(model, key, value)
await session.commit()
await session.refresh(model)
- if search_space_id is not None:
- await _clear_invalid_roles(session, search_space_id)
+ if workspace_id is not None:
+ await _clear_invalid_roles(session, workspace_id)
await session.commit()
await session.refresh(model)
return _model_read(model)
@@ -752,36 +750,32 @@ async def test_connection_model(
)
-@router.get(
- "/search-spaces/{search_space_id}/model-roles", response_model=ModelRolesRead
-)
+@router.get("/workspaces/{workspace_id}/model-roles", response_model=ModelRolesRead)
async def get_model_roles(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.LLM_CONFIGS_CREATE.value,
- "You don't have permission to view model roles in this search space",
+ "You don't have permission to view model roles in this workspace",
)
- search_space = await _clear_invalid_roles(session, search_space_id)
+ workspace = await _clear_invalid_roles(session, workspace_id)
await session.commit()
- await session.refresh(search_space)
+ await session.refresh(workspace)
return ModelRolesRead(
- chat_model_id=search_space.chat_model_id,
- vision_model_id=search_space.vision_model_id,
- image_gen_model_id=search_space.image_gen_model_id,
+ chat_model_id=workspace.chat_model_id,
+ vision_model_id=workspace.vision_model_id,
+ image_gen_model_id=workspace.image_gen_model_id,
)
-@router.put(
- "/search-spaces/{search_space_id}/model-roles", response_model=ModelRolesRead
-)
+@router.put("/workspaces/{workspace_id}/model-roles", response_model=ModelRolesRead)
async def update_model_roles(
- search_space_id: int,
+ workspace_id: int,
data: ModelRolesUpdate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -789,51 +783,51 @@ async def update_model_roles(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.LLM_CONFIGS_UPDATE.value,
- "You don't have permission to update model roles in this search space",
+ "You don't have permission to update model roles in this workspace",
)
- search_space = await _get_search_space(session, search_space_id)
+ workspace = await _get_workspace(session, workspace_id)
updates = data.model_dump(exclude_unset=True)
if "chat_model_id" in updates:
- previous_chat_model_id = search_space.chat_model_id
+ previous_chat_model_id = workspace.chat_model_id
next_chat_model_id = await _validate_role_model_id(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
model_id=updates["chat_model_id"],
capability="chat",
)
- search_space.chat_model_id = next_chat_model_id
+ workspace.chat_model_id = next_chat_model_id
if next_chat_model_id != previous_chat_model_id:
await session.execute(
update(NewChatThread)
- .where(NewChatThread.search_space_id == search_space_id)
+ .where(NewChatThread.workspace_id == workspace_id)
.values(pinned_llm_config_id=None)
)
logger.info(
- "Cleared auto model pins for search_space_id=%s after chat_model_id change (%s -> %s)",
- search_space_id,
+ "Cleared auto model pins for workspace_id=%s after chat_model_id change (%s -> %s)",
+ workspace_id,
previous_chat_model_id,
next_chat_model_id,
)
if "vision_model_id" in updates:
- search_space.vision_model_id = await _validate_role_model_id(
+ workspace.vision_model_id = await _validate_role_model_id(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
model_id=updates["vision_model_id"],
capability="vision",
)
if "image_gen_model_id" in updates:
- search_space.image_gen_model_id = await _validate_role_model_id(
+ workspace.image_gen_model_id = await _validate_role_model_id(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
model_id=updates["image_gen_model_id"],
capability="image_gen",
)
await session.commit()
- await session.refresh(search_space)
+ await session.refresh(workspace)
return ModelRolesRead(
- chat_model_id=search_space.chat_model_id,
- vision_model_id=search_space.vision_model_id,
- image_gen_model_id=search_space.image_gen_model_id,
+ chat_model_id=workspace.chat_model_id,
+ vision_model_id=workspace.vision_model_id,
+ image_gen_model_id=workspace.image_gen_model_id,
)
diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index 951682e47..89d1cef95 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -45,9 +45,9 @@ from app.db import (
NewChatMessageRole,
NewChatThread,
Permission,
- SearchSpace,
TokenUsage,
User,
+ Workspace,
get_async_session,
shielded_async_session,
)
@@ -525,7 +525,7 @@ async def check_thread_access(
Access is granted if:
- User is the creator of the thread
- Thread visibility is SEARCH_SPACE (any member can access) - for read/update operations only
- - Thread is a legacy thread (created_by_id is NULL) - only if user is search space owner
+ - Thread is a legacy thread (created_by_id is NULL) - only if user is workspace owner
Args:
session: Database session
@@ -558,16 +558,14 @@ async def check_thread_access(
return True
# For legacy threads (created before visibility feature),
- # only the search space owner can access
+ # only the workspace owner can access
if is_legacy:
- search_space_query = select(SearchSpace).filter(
- SearchSpace.id == thread.search_space_id
- )
- search_space_result = await session.execute(search_space_query)
- search_space = search_space_result.scalar_one_or_none()
- is_search_space_owner = search_space and search_space.user_id == user.id
+ workspace_query = select(Workspace).filter(Workspace.id == thread.workspace_id)
+ workspace_result = await session.execute(workspace_query)
+ workspace = workspace_result.scalar_one_or_none()
+ is_workspace_owner = workspace and workspace.user_id == user.id
- if is_search_space_owner:
+ if is_workspace_owner:
return True
# Legacy threads are not accessible to non-owners
raise HTTPException(
@@ -593,23 +591,23 @@ async def check_thread_access(
@router.get("/threads", response_model=ThreadListResponse)
async def list_threads(
- search_space_id: int,
+ workspace_id: int,
limit: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
- List all accessible threads for the current user in a search space.
+ List all accessible threads for the current user in a workspace.
Returns threads and archived_threads for ThreadListPrimitive.
A user can see threads that are:
- Created by them (regardless of visibility)
- - Shared with the search space (visibility = SEARCH_SPACE)
- - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
+ - Shared with the workspace (visibility = SEARCH_SPACE)
+ - Legacy threads with no creator (created_by_id is NULL) - only if user is workspace owner
Args:
- search_space_id: The search space to list threads for
+ workspace_id: The workspace to list threads for
limit: Optional limit on number of threads to return (applies to active threads only)
Requires CHATS_READ permission.
@@ -618,36 +616,34 @@ async def list_threads(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.CHATS_READ.value,
- "You don't have permission to read chats in this search space",
+ "You don't have permission to read chats in this workspace",
)
- # Check if user is the search space owner (for legacy thread visibility)
- search_space_query = select(SearchSpace).filter(
- SearchSpace.id == search_space_id
- )
- search_space_result = await session.execute(search_space_query)
- search_space = search_space_result.scalar_one_or_none()
- is_search_space_owner = search_space and search_space.user_id == user.id
+ # Check if user is the workspace owner (for legacy thread visibility)
+ workspace_query = select(Workspace).filter(Workspace.id == workspace_id)
+ workspace_result = await session.execute(workspace_query)
+ workspace = workspace_result.scalar_one_or_none()
+ is_workspace_owner = workspace and workspace.user_id == user.id
# Build filter conditions:
# 1. Created by the current user (any visibility)
- # 2. Shared with the search space (visibility = SEARCH_SPACE)
- # 3. Legacy threads (created_by_id is NULL) - only visible to search space owner
+ # 2. Shared with the workspace (visibility = SEARCH_SPACE)
+ # 3. Legacy threads (created_by_id is NULL) - only visible to workspace owner
filter_conditions = [
NewChatThread.created_by_id == user.id,
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
]
- # Only include legacy threads for the search space owner
- if is_search_space_owner:
+ # Only include legacy threads for the workspace owner
+ if is_workspace_owner:
filter_conditions.append(NewChatThread.created_by_id.is_(None))
query = (
select(NewChatThread)
.filter(
- NewChatThread.search_space_id == search_space_id,
+ NewChatThread.workspace_id == workspace_id,
or_(*filter_conditions),
)
.order_by(NewChatThread.updated_at.desc())
@@ -663,7 +659,7 @@ async def list_threads(
for thread in all_threads:
# Legacy threads (no creator) are treated as own threads for owner
is_own_thread = thread.created_by_id == user.id or (
- thread.created_by_id is None and is_search_space_owner
+ thread.created_by_id is None and is_workspace_owner
)
item = ThreadListItem(
id=thread.id,
@@ -701,22 +697,22 @@ async def list_threads(
@router.get("/threads/search", response_model=list[ThreadListItem])
async def search_threads(
- search_space_id: int,
+ workspace_id: int,
title: str,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
- Search accessible threads by title in a search space.
+ Search accessible threads by title in a workspace.
A user can search threads that are:
- Created by them (regardless of visibility)
- - Shared with the search space (visibility = SEARCH_SPACE)
- - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
+ - Shared with the workspace (visibility = SEARCH_SPACE)
+ - Legacy threads with no creator (created_by_id is NULL) - only if user is workspace owner
Args:
- search_space_id: The search space to search in
+ workspace_id: The workspace to search in
title: The search query (case-insensitive partial match)
Requires CHATS_READ permission.
@@ -725,18 +721,16 @@ async def search_threads(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.CHATS_READ.value,
- "You don't have permission to read chats in this search space",
+ "You don't have permission to read chats in this workspace",
)
- # Check if user is the search space owner (for legacy thread visibility)
- search_space_query = select(SearchSpace).filter(
- SearchSpace.id == search_space_id
- )
- search_space_result = await session.execute(search_space_query)
- search_space = search_space_result.scalar_one_or_none()
- is_search_space_owner = search_space and search_space.user_id == user.id
+ # Check if user is the workspace owner (for legacy thread visibility)
+ workspace_query = select(Workspace).filter(Workspace.id == workspace_id)
+ workspace_result = await session.execute(workspace_query)
+ workspace = workspace_result.scalar_one_or_none()
+ is_workspace_owner = workspace and workspace.user_id == user.id
# Build filter conditions
filter_conditions = [
@@ -744,15 +738,15 @@ async def search_threads(
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
]
- # Only include legacy threads for the search space owner
- if is_search_space_owner:
+ # Only include legacy threads for the workspace owner
+ if is_workspace_owner:
filter_conditions.append(NewChatThread.created_by_id.is_(None))
# Search accessible threads by title (case-insensitive)
query = (
select(NewChatThread)
.filter(
- NewChatThread.search_space_id == search_space_id,
+ NewChatThread.workspace_id == workspace_id,
NewChatThread.title.ilike(f"%{title}%"),
or_(*filter_conditions),
)
@@ -772,7 +766,7 @@ async def search_threads(
# Legacy threads (no creator) are treated as own threads for owner
is_own_thread=(
thread.created_by_id == user.id
- or (thread.created_by_id is None and is_search_space_owner)
+ or (thread.created_by_id is None and is_workspace_owner)
),
created_at=thread.created_at,
updated_at=thread.updated_at,
@@ -812,9 +806,9 @@ async def create_thread(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_CREATE.value,
- "You don't have permission to create chats in this search space",
+ "You don't have permission to create chats in this workspace",
)
now = datetime.now(UTC)
@@ -822,7 +816,7 @@ async def create_thread(
title=thread.title,
archived=thread.archived,
visibility=thread.visibility,
- search_space_id=thread.search_space_id,
+ workspace_id=thread.workspace_id,
created_by_id=user.id,
updated_at=now,
)
@@ -879,13 +873,13 @@ async def get_thread_messages(
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
- # Check permission to read chats in this search space
+ # Check permission to read chats in this workspace
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_READ.value,
- "You don't have permission to read chats in this search space",
+ "You don't have permission to read chats in this workspace",
)
# Check thread-level access based on visibility
@@ -971,9 +965,9 @@ async def get_thread_full(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_READ.value,
- "You don't have permission to read chats in this search space",
+ "You don't have permission to read chats in this workspace",
)
# Check thread-level access based on visibility
@@ -1035,9 +1029,9 @@ async def update_thread(
await check_permission(
session,
auth,
- db_thread.search_space_id,
+ db_thread.workspace_id,
Permission.CHATS_UPDATE.value,
- "You don't have permission to update chats in this search space",
+ "You don't have permission to update chats in this workspace",
)
# For PRIVATE threads, only the creator can update
@@ -1104,16 +1098,16 @@ async def delete_thread(
await check_permission(
session,
auth,
- db_thread.search_space_id,
+ db_thread.workspace_id,
Permission.CHATS_DELETE.value,
- "You don't have permission to delete chats in this search space",
+ "You don't have permission to delete chats in this workspace",
)
# For PRIVATE threads, only the creator can delete
# For SEARCH_SPACE threads, any member with permission can delete
# Legacy threads (created_by_id is NULL) have no recorded creator,
# so we skip strict ownership and fall through to legacy handling
- # which allows the search space owner to delete them
+ # which allows the workspace owner to delete them
if db_thread.visibility == ChatVisibility.PRIVATE:
await check_thread_access(
session,
@@ -1162,7 +1156,7 @@ async def update_thread_visibility(
Only the creator of the thread can change its visibility.
- PRIVATE: Only the creator can access the thread (default)
- - SEARCH_SPACE: All members of the search space can access the thread
+ - SEARCH_SPACE: All members of the workspace can access the thread
Requires CHATS_UPDATE permission.
"""
@@ -1178,9 +1172,9 @@ async def update_thread_visibility(
await check_permission(
session,
auth,
- db_thread.search_space_id,
+ db_thread.workspace_id,
Permission.CHATS_UPDATE.value,
- "You don't have permission to update chats in this search space",
+ "You don't have permission to update chats in this workspace",
)
# Only the creator can change visibility
@@ -1381,9 +1375,9 @@ async def append_message(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_UPDATE.value,
- "You don't have permission to update chats in this search space",
+ "You don't have permission to update chats in this workspace",
)
# Check thread-level access based on visibility
@@ -1552,7 +1546,7 @@ async def append_message(
call_details=token_usage_data.get("call_details"),
thread_id=thread_id,
message_id=db_message.id,
- search_space_id=thread.search_space_id,
+ workspace_id=thread.workspace_id,
user_id=user_uuid,
)
.on_conflict_do_nothing(
@@ -1632,9 +1626,9 @@ async def list_messages(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_READ.value,
- "You don't have permission to read chats in this search space",
+ "You don't have permission to read chats in this workspace",
)
# Check thread-level access based on visibility
@@ -1730,9 +1724,9 @@ async def handle_new_chat(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_CREATE.value,
- "You don't have permission to chat in this search space",
+ "You don't have permission to chat in this workspace",
)
# Check thread-level access based on visibility
@@ -1744,24 +1738,24 @@ async def handle_new_chat(
local_mounts=request.local_filesystem_mounts,
)
- # Get search space to check LLM config preferences
- search_space_result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
+ # Get workspace to check LLM config preferences
+ workspace_result = await session.execute(
+ select(Workspace).filter(Workspace.id == request.workspace_id)
)
- search_space = search_space_result.scalars().first()
+ workspace = workspace_result.scalars().first()
- if not search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
# Use the converged model-connections role for chat operations.
# Positive IDs load Model + Connection rows; negative IDs load
# virtual GLOBAL models; 0 means Auto.
llm_config_id = (
- search_space.chat_model_id if search_space.chat_model_id is not None else 0
+ workspace.chat_model_id if workspace.chat_model_id is not None else 0
)
# Release the read-transaction so we don't hold ACCESS SHARE locks
- # on searchspaces/documents for the entire duration of the stream.
+ # on workspaces/documents for the entire duration of the stream.
# expire_on_commit=False keeps loaded ORM attrs usable.
await session.commit()
# Close the dependency session now so its connection returns to
@@ -1791,7 +1785,7 @@ async def handle_new_chat(
return StreamingResponse(
stream_new_chat(
user_query=request.user_query,
- search_space_id=request.search_space_id,
+ workspace_id=request.workspace_id,
chat_id=request.chat_id,
user_id=str(user.id),
llm_config_id=llm_config_id,
@@ -1849,9 +1843,9 @@ async def cancel_active_turn(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_UPDATE.value,
- "You don't have permission to update chats in this search space",
+ "You don't have permission to update chats in this workspace",
)
await check_thread_access(session, thread, user)
@@ -1901,9 +1895,9 @@ async def get_turn_status(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_READ.value,
- "You don't have permission to view chats in this search space",
+ "You don't have permission to view chats in this workspace",
)
await check_thread_access(session, thread, user)
@@ -1965,9 +1959,9 @@ async def regenerate_response(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_UPDATE.value,
- "You don't have permission to update chats in this search space",
+ "You don't have permission to update chats in this workspace",
)
# Check thread-level access based on visibility
@@ -2234,21 +2228,21 @@ async def regenerate_response(
seen_turns.add(tid)
revert_turn_ids.append(tid)
- # Get search space for LLM config
- search_space_result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
+ # Get workspace for LLM config
+ workspace_result = await session.execute(
+ select(Workspace).filter(Workspace.id == request.workspace_id)
)
- search_space = search_space_result.scalars().first()
+ workspace = workspace_result.scalars().first()
- if not search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
llm_config_id = (
- search_space.chat_model_id if search_space.chat_model_id is not None else 0
+ workspace.chat_model_id if workspace.chat_model_id is not None else 0
)
# Release the read-transaction so we don't hold ACCESS SHARE locks
- # on searchspaces/documents for the entire duration of the stream.
+ # on workspaces/documents for the entire duration of the stream.
# expire_on_commit=False keeps loaded ORM attrs (including messages_to_delete PKs) usable.
await session.commit()
await session.close()
@@ -2288,7 +2282,7 @@ async def regenerate_response(
try:
async for chunk in stream_new_chat(
user_query=str(user_query_to_use),
- search_space_id=request.search_space_id,
+ workspace_id=request.workspace_id,
chat_id=thread_id,
user_id=str(user.id),
llm_config_id=llm_config_id,
@@ -2390,9 +2384,9 @@ async def resume_chat(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_CREATE.value,
- "You don't have permission to chat in this search space",
+ "You don't have permission to chat in this workspace",
)
await check_thread_access(session, thread, user)
@@ -2403,29 +2397,29 @@ async def resume_chat(
local_mounts=request.local_filesystem_mounts,
)
- search_space_result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
+ workspace_result = await session.execute(
+ select(Workspace).filter(Workspace.id == request.workspace_id)
)
- search_space = search_space_result.scalars().first()
+ workspace = workspace_result.scalars().first()
- if not search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
llm_config_id = (
- search_space.chat_model_id if search_space.chat_model_id is not None else 0
+ workspace.chat_model_id if workspace.chat_model_id is not None else 0
)
decisions = [d.model_dump() for d in request.decisions]
# Release the read-transaction so we don't hold ACCESS SHARE locks
- # on searchspaces/documents for the entire duration of the stream.
+ # on workspaces/documents for the entire duration of the stream.
await session.commit()
await session.close()
return StreamingResponse(
stream_resume_chat(
chat_id=thread_id,
- search_space_id=request.search_space_id,
+ workspace_id=request.workspace_id,
decisions=decisions,
user_id=str(user.id),
llm_config_id=llm_config_id,
diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py
index eb3c66b5f..e421ceff4 100644
--- a/surfsense_backend/app/routes/notes_routes.py
+++ b/surfsense_backend/app/routes/notes_routes.py
@@ -23,9 +23,9 @@ class CreateNoteRequest(BaseModel):
source_markdown: str | None = None
-@router.post("/search-spaces/{search_space_id}/notes", response_model=DocumentRead)
+@router.post("/workspaces/{workspace_id}/notes", response_model=DocumentRead)
async def create_note(
- search_space_id: int,
+ workspace_id: int,
request: CreateNoteRequest,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -40,9 +40,9 @@ async def create_note(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_CREATE.value,
- "You don't have permission to create notes in this search space",
+ "You don't have permission to create notes in this workspace",
)
if not request.title or not request.title.strip():
@@ -58,7 +58,7 @@ async def create_note(
# Create document with NOTE type
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=request.title.strip(),
document_type=DocumentType.NOTE,
content="", # Empty initially, will be populated on first save/reindex
@@ -83,7 +83,7 @@ async def create_note(
content_hash=document.content_hash,
unique_identifier_hash=document.unique_identifier_hash,
document_metadata=document.document_metadata,
- search_space_id=document.search_space_id,
+ workspace_id=document.workspace_id,
created_at=document.created_at,
updated_at=document.updated_at,
created_by_id=document.created_by_id,
@@ -91,11 +91,11 @@ async def create_note(
@router.get(
- "/search-spaces/{search_space_id}/notes",
+ "/workspaces/{workspace_id}/notes",
response_model=PaginatedResponse[DocumentRead],
)
async def list_notes(
- search_space_id: int,
+ workspace_id: int,
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
@@ -103,7 +103,7 @@ async def list_notes(
auth: AuthContext = Depends(get_auth_context),
):
"""
- List all notes in a search space.
+ List all notes in a workspace.
Requires DOCUMENTS_READ permission.
"""
@@ -111,16 +111,16 @@ async def list_notes(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_READ.value,
- "You don't have permission to read notes in this search space",
+ "You don't have permission to read notes in this workspace",
)
from sqlalchemy import func
# Build query
query = select(Document).where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.NOTE,
)
@@ -128,7 +128,7 @@ async def list_notes(
count_query = select(func.count()).select_from(
select(Document)
.where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.NOTE,
)
.subquery()
@@ -164,7 +164,7 @@ async def list_notes(
content_hash=doc.content_hash,
unique_identifier_hash=doc.unique_identifier_hash,
document_metadata=doc.document_metadata,
- search_space_id=doc.search_space_id,
+ workspace_id=doc.workspace_id,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
@@ -188,9 +188,9 @@ async def list_notes(
)
-@router.delete("/search-spaces/{search_space_id}/notes/{note_id}")
+@router.delete("/workspaces/{workspace_id}/notes/{note_id}")
async def delete_note(
- search_space_id: int,
+ workspace_id: int,
note_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -204,16 +204,16 @@ async def delete_note(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.DOCUMENTS_DELETE.value,
- "You don't have permission to delete notes in this search space",
+ "You don't have permission to delete notes in this workspace",
)
# Get document
result = await session.execute(
select(Document).where(
Document.id == note_id,
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.NOTE,
)
)
diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py
index b0fafb242..830e1c641 100644
--- a/surfsense_backend/app/routes/notion_add_connector_route.py
+++ b/surfsense_backend/app/routes/notion_add_connector_route.py
@@ -84,7 +84,7 @@ async def connect_notion(
Initiate Notion OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -145,7 +145,7 @@ async def reauth_notion(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.NOTION_CONNECTOR,
)
@@ -345,7 +345,7 @@ async def notion_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.NOTION_CONNECTOR,
)
@@ -408,7 +408,7 @@ async def notion_callback(
connector_type=SearchSourceConnectorType.NOTION_CONNECTOR,
is_indexable=True,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
diff --git a/surfsense_backend/app/routes/oauth_connector_base.py b/surfsense_backend/app/routes/oauth_connector_base.py
index 483caa6c2..42e99c262 100644
--- a/surfsense_backend/app/routes/oauth_connector_base.py
+++ b/surfsense_backend/app/routes/oauth_connector_base.py
@@ -415,7 +415,7 @@ class OAuthConnectorRoute:
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type == oauth.connector_type,
)
)
@@ -530,7 +530,7 @@ class OAuthConnectorRoute:
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type == oauth.connector_type,
)
)
@@ -597,7 +597,7 @@ class OAuthConnectorRoute:
connector_type=oauth.connector_type,
is_indexable=oauth.is_indexable,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
diff --git a/surfsense_backend/app/routes/obsidian_plugin_routes.py b/surfsense_backend/app/routes/obsidian_plugin_routes.py
index 56623d61a..65adef33b 100644
--- a/surfsense_backend/app/routes/obsidian_plugin_routes.py
+++ b/surfsense_backend/app/routes/obsidian_plugin_routes.py
@@ -22,8 +22,8 @@ from app.db import (
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
- SearchSpace,
User,
+ Workspace,
get_async_session,
)
from app.notifications.service import NotificationService
@@ -54,7 +54,7 @@ from app.services.obsidian_plugin_indexer import (
)
from app.tasks.celery_tasks.obsidian_tasks import index_obsidian_attachment_task
from app.users import allow_any_principal, get_auth_context
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@@ -102,7 +102,7 @@ async def _start_obsidian_sync_notification(
operation_id=operation_id,
title=f"Syncing: {connector_name}",
message="Syncing from Obsidian plugin",
- search_space_id=connector.search_space_id,
+ workspace_id=connector.workspace_id,
initial_metadata={
"connector_id": connector.id,
"connector_name": connector_name,
@@ -195,7 +195,7 @@ async def _resolve_vault_connector(
connector = (await session.execute(stmt)).scalars().first()
if connector is not None:
- await check_search_space_access(session, auth, connector.search_space_id)
+ await check_workspace_access(session, auth, connector.workspace_id)
return connector
raise HTTPException(
@@ -222,17 +222,17 @@ def _queue_obsidian_attachment(
)
-async def _ensure_search_space_access(
+async def _ensure_workspace_access(
session: AsyncSession,
*,
auth: AuthContext,
- search_space_id: int,
-) -> SearchSpace:
- """Owner-only access to the search space (shared spaces are a follow-up)."""
+ workspace_id: int,
+) -> Workspace:
+ """Owner-only access to the workspace (shared spaces are a follow-up)."""
user = auth.user
result = await session.execute(
- select(SearchSpace).where(
- and_(SearchSpace.id == search_space_id, SearchSpace.user_id == user.id)
+ select(Workspace).where(
+ and_(Workspace.id == workspace_id, Workspace.user_id == user.id)
)
)
space = result.scalars().first()
@@ -241,10 +241,10 @@ async def _ensure_search_space_access(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "SEARCH_SPACE_FORBIDDEN",
- "message": "You don't own that search space.",
+ "message": "You don't own that workspace.",
},
)
- await check_search_space_access(session, auth, search_space_id)
+ await check_workspace_access(session, auth, workspace_id)
return space
@@ -326,8 +326,8 @@ async def obsidian_connect(
Fingerprint collisions on (1) trigger ``merge_obsidian_connectors`` so
the partial unique index can never produce two live rows for one vault.
"""
- await _ensure_search_space_access(
- session, auth=auth, search_space_id=payload.search_space_id
+ await _ensure_workspace_access(
+ session, auth=auth, workspace_id=payload.workspace_id
)
user = auth.user
@@ -354,7 +354,7 @@ async def obsidian_connect(
response = ConnectResponse(
connector_id=collision.id,
vault_id=collision_cfg["vault_id"],
- search_space_id=collision.search_space_id,
+ workspace_id=collision.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
@@ -363,12 +363,12 @@ async def obsidian_connect(
existing_by_vid.name = display_name
existing_by_vid.config = cfg
- existing_by_vid.search_space_id = payload.search_space_id
+ existing_by_vid.workspace_id = payload.workspace_id
existing_by_vid.is_indexable = False
response = ConnectResponse(
connector_id=existing_by_vid.id,
vault_id=payload.vault_id,
- search_space_id=existing_by_vid.search_space_id,
+ workspace_id=existing_by_vid.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
@@ -387,7 +387,7 @@ async def obsidian_connect(
response = ConnectResponse(
connector_id=existing_by_fp.id,
vault_id=survivor_cfg["vault_id"],
- search_space_id=existing_by_fp.search_space_id,
+ workspace_id=existing_by_fp.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
@@ -406,12 +406,12 @@ async def obsidian_connect(
is_indexable=False,
config=cfg,
user_id=user.id,
- search_space_id=payload.search_space_id,
+ workspace_id=payload.workspace_id,
)
.on_conflict_do_nothing()
.returning(
SearchSourceConnector.id,
- SearchSourceConnector.search_space_id,
+ SearchSourceConnector.workspace_id,
)
)
inserted = (await session.execute(insert_stmt)).first()
@@ -419,7 +419,7 @@ async def obsidian_connect(
response = ConnectResponse(
connector_id=inserted.id,
vault_id=payload.vault_id,
- search_space_id=inserted.search_space_id,
+ workspace_id=inserted.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
@@ -441,7 +441,7 @@ async def obsidian_connect(
response = ConnectResponse(
connector_id=winner.id,
vault_id=(winner.config or {})["vault_id"],
- search_space_id=winner.search_space_id,
+ workspace_id=winner.workspace_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
diff --git a/surfsense_backend/app/routes/onedrive_add_connector_route.py b/surfsense_backend/app/routes/onedrive_add_connector_route.py
index 9c55d4fe7..9ccd9e80c 100644
--- a/surfsense_backend/app/routes/onedrive_add_connector_route.py
+++ b/surfsense_backend/app/routes/onedrive_add_connector_route.py
@@ -36,7 +36,7 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -134,7 +134,7 @@ async def reauth_onedrive(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
@@ -312,7 +312,7 @@ async def onedrive_callback(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == space_id,
+ SearchSourceConnector.workspace_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
@@ -379,7 +379,7 @@ async def onedrive_callback(
connector_type=SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
is_indexable=True,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
@@ -438,7 +438,7 @@ async def list_onedrive_folders(
status_code=404, detail="OneDrive connector not found or access denied"
)
- await check_search_space_access(session, auth, connector.search_space_id)
+ await check_workspace_access(session, auth, connector.workspace_id)
onedrive_client = OneDriveClient(session, connector_id)
items, error = await list_folder_contents(onedrive_client, parent_id=parent_id)
diff --git a/surfsense_backend/app/routes/prompts_routes.py b/surfsense_backend/app/routes/prompts_routes.py
index b4cb1466c..b7a989d82 100644
--- a/surfsense_backend/app/routes/prompts_routes.py
+++ b/surfsense_backend/app/routes/prompts_routes.py
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
-from app.db import Prompt, SearchSpaceMembership, get_async_session
+from app.db import Prompt, WorkspaceMembership, get_async_session
from app.schemas.prompts import (
PromptCreate,
PromptRead,
@@ -18,14 +18,14 @@ router = APIRouter(tags=["Prompts"])
@router.get("/prompts", response_model=list[PromptRead])
async def list_prompts(
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(require_session_context),
):
user = auth.user
query = select(Prompt).where(Prompt.user_id == user.id)
- if search_space_id is not None:
- query = query.where(Prompt.search_space_id == search_space_id)
+ if workspace_id is not None:
+ query = query.where(Prompt.workspace_id == workspace_id)
query = query.order_by(Prompt.created_at.desc())
result = await session.execute(query)
return result.scalars().all()
@@ -38,22 +38,22 @@ async def create_prompt(
auth: AuthContext = Depends(require_session_context),
):
user = auth.user
- if body.search_space_id is not None:
+ if body.workspace_id is not None:
membership = await session.execute(
- select(SearchSpaceMembership).where(
- SearchSpaceMembership.user_id == user.id,
- SearchSpaceMembership.search_space_id == body.search_space_id,
+ select(WorkspaceMembership).where(
+ WorkspaceMembership.user_id == user.id,
+ WorkspaceMembership.workspace_id == body.workspace_id,
)
)
if not membership.scalar_one_or_none():
raise HTTPException(
status_code=403,
- detail="You are not a member of this search space",
+ detail="You are not a member of this workspace",
)
prompt = Prompt(
user_id=user.id,
- search_space_id=body.search_space_id,
+ workspace_id=body.workspace_id,
name=body.name,
prompt=body.prompt,
mode=body.mode,
diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py
index e1122b2bb..2d8f58d33 100644
--- a/surfsense_backend/app/routes/rbac_routes.py
+++ b/surfsense_backend/app/routes/rbac_routes.py
@@ -2,9 +2,9 @@
RBAC (Role-Based Access Control) routes for managing roles, memberships, and invites.
Endpoints:
-- /searchspaces/{search_space_id}/roles - CRUD for roles
-- /searchspaces/{search_space_id}/members - CRUD for memberships
-- /searchspaces/{search_space_id}/invites - CRUD for invites
+- /workspaces/{workspace_id}/roles - CRUD for roles
+- /workspaces/{workspace_id}/members - CRUD for memberships
+- /workspaces/{workspace_id}/invites - CRUD for invites
- /invites/{invite_code}/info - Get invite info (public)
- /invites/accept - Accept an invite
- /permissions - List all available permissions
@@ -21,11 +21,11 @@ from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
from app.db import (
Permission,
- SearchSpace,
- SearchSpaceInvite,
- SearchSpaceMembership,
- SearchSpaceRole,
User,
+ Workspace,
+ WorkspaceInvite,
+ WorkspaceMembership,
+ WorkspaceRole,
get_async_session,
)
from app.schemas import (
@@ -42,12 +42,12 @@ from app.schemas import (
RoleCreate,
RoleRead,
RoleUpdate,
- UserSearchSpaceAccess,
+ UserWorkspaceAccess,
)
from app.users import get_auth_context
from app.utils.rbac import (
check_permission,
- check_search_space_access,
+ check_workspace_access,
generate_invite_code,
get_default_role,
get_user_permissions,
@@ -63,10 +63,10 @@ router = APIRouter()
# Human-readable descriptions for each permission
PERMISSION_DESCRIPTIONS = {
# Documents
- "documents:create": "Add new documents, files, and content to the search space",
- "documents:read": "View and search documents in the search space",
+ "documents:create": "Add new documents, files, and content to the workspace",
+ "documents:read": "View and search documents in the workspace",
"documents:update": "Edit existing documents and their metadata",
- "documents:delete": "Remove documents from the search space",
+ "documents:delete": "Remove documents from the workspace",
# Chats
"chats:create": "Start new AI chat conversations",
"chats:read": "View chat history and conversations",
@@ -97,7 +97,7 @@ PERMISSION_DESCRIPTIONS = {
# Members
"members:invite": "Send invitations to new team members",
"members:view": "View the list of team members",
- "members:remove": "Remove members from the search space",
+ "members:remove": "Remove members from the workspace",
"members:manage_roles": "Assign and change member roles",
# Roles
"roles:create": "Create new custom roles",
@@ -105,16 +105,16 @@ PERMISSION_DESCRIPTIONS = {
"roles:update": "Modify role permissions",
"roles:delete": "Remove custom roles",
# Settings
- "settings:view": "View search space settings",
- "settings:update": "Modify search space settings",
- "settings:delete": "Delete the entire search space",
+ "settings:view": "View workspace settings",
+ "settings:update": "Modify workspace settings",
+ "settings:delete": "Delete the entire workspace",
# API access
- "api_access:manage": "Enable or disable programmatic API access for a search space",
+ "api_access:manage": "Enable or disable programmatic API access for a workspace",
# Automations
"automations:create": "Create automations from chat or JSON",
"automations:read": "View automations, their triggers, and run history",
"automations:update": "Edit automations and manage their triggers",
- "automations:delete": "Remove automations from the search space",
+ "automations:delete": "Remove automations from the workspace",
"automations:execute": "Manually fire automations",
# Full access
"*": "Full access to all features and settings",
@@ -152,39 +152,39 @@ async def list_all_permissions(
@router.post(
- "/searchspaces/{search_space_id}/roles",
+ "/workspaces/{workspace_id}/roles",
response_model=RoleRead,
)
async def create_role(
- search_space_id: int,
+ workspace_id: int,
role_data: RoleCreate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- Create a new custom role in a search space.
+ Create a new custom role in a workspace.
Requires ROLES_CREATE permission.
"""
try:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.ROLES_CREATE.value,
"You don't have permission to create roles",
)
# Check if role with same name already exists
result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.search_space_id == search_space_id,
- SearchSpaceRole.name == role_data.name,
+ select(WorkspaceRole).filter(
+ WorkspaceRole.workspace_id == workspace_id,
+ WorkspaceRole.name == role_data.name,
)
)
if result.scalars().first():
raise HTTPException(
status_code=409,
- detail=f"A role with name '{role_data.name}' already exists in this search space",
+ detail=f"A role with name '{role_data.name}' already exists in this workspace",
)
# Validate permissions
@@ -199,23 +199,23 @@ async def create_role(
# If setting is_default to True, unset any existing default
if role_data.is_default:
await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.search_space_id == search_space_id,
- SearchSpaceRole.is_default == True, # noqa: E712
+ select(WorkspaceRole).filter(
+ WorkspaceRole.workspace_id == workspace_id,
+ WorkspaceRole.is_default == True, # noqa: E712
)
)
existing_defaults = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.search_space_id == search_space_id,
- SearchSpaceRole.is_default == True, # noqa: E712
+ select(WorkspaceRole).filter(
+ WorkspaceRole.workspace_id == workspace_id,
+ WorkspaceRole.is_default == True, # noqa: E712
)
)
for existing in existing_defaults.scalars().all():
existing.is_default = False
- db_role = SearchSpaceRole(
+ db_role = WorkspaceRole(
**role_data.model_dump(),
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
is_system_role=False,
)
session.add(db_role)
@@ -234,31 +234,29 @@ async def create_role(
@router.get(
- "/searchspaces/{search_space_id}/roles",
+ "/workspaces/{workspace_id}/roles",
response_model=list[RoleRead],
)
async def list_roles(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- List all roles in a search space.
+ List all roles in a workspace.
Requires ROLES_READ permission.
"""
try:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.ROLES_READ.value,
"You don't have permission to view roles",
)
result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.search_space_id == search_space_id
- )
+ select(WorkspaceRole).filter(WorkspaceRole.workspace_id == workspace_id)
)
return result.scalars().all()
@@ -271,11 +269,11 @@ async def list_roles(
@router.get(
- "/searchspaces/{search_space_id}/roles/{role_id}",
+ "/workspaces/{workspace_id}/roles/{role_id}",
response_model=RoleRead,
)
async def get_role(
- search_space_id: int,
+ workspace_id: int,
role_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -288,15 +286,15 @@ async def get_role(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.ROLES_READ.value,
"You don't have permission to view roles",
)
result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.id == role_id,
- SearchSpaceRole.search_space_id == search_space_id,
+ select(WorkspaceRole).filter(
+ WorkspaceRole.id == role_id,
+ WorkspaceRole.workspace_id == workspace_id,
)
)
role = result.scalars().first()
@@ -315,11 +313,11 @@ async def get_role(
@router.put(
- "/searchspaces/{search_space_id}/roles/{role_id}",
+ "/workspaces/{workspace_id}/roles/{role_id}",
response_model=RoleRead,
)
async def update_role(
- search_space_id: int,
+ workspace_id: int,
role_id: int,
role_update: RoleUpdate,
session: AsyncSession = Depends(get_async_session),
@@ -334,15 +332,15 @@ async def update_role(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.ROLES_UPDATE.value,
"You don't have permission to update roles",
)
result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.id == role_id,
- SearchSpaceRole.search_space_id == search_space_id,
+ select(WorkspaceRole).filter(
+ WorkspaceRole.id == role_id,
+ WorkspaceRole.workspace_id == workspace_id,
)
)
db_role = result.scalars().first()
@@ -365,9 +363,9 @@ async def update_role(
# Check for name conflict if updating name
if "name" in update_data and update_data["name"] != db_role.name:
existing = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.search_space_id == search_space_id,
- SearchSpaceRole.name == update_data["name"],
+ select(WorkspaceRole).filter(
+ WorkspaceRole.workspace_id == workspace_id,
+ WorkspaceRole.name == update_data["name"],
)
)
if existing.scalars().first():
@@ -390,9 +388,9 @@ async def update_role(
if update_data.get("is_default") and not db_role.is_default:
# Unset existing default
existing_defaults = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.search_space_id == search_space_id,
- SearchSpaceRole.is_default == True, # noqa: E712
+ select(WorkspaceRole).filter(
+ WorkspaceRole.workspace_id == workspace_id,
+ WorkspaceRole.is_default == True, # noqa: E712
)
)
for existing in existing_defaults.scalars().all():
@@ -415,9 +413,9 @@ async def update_role(
) from e
-@router.delete("/searchspaces/{search_space_id}/roles/{role_id}")
+@router.delete("/workspaces/{workspace_id}/roles/{role_id}")
async def delete_role(
- search_space_id: int,
+ workspace_id: int,
role_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -431,15 +429,15 @@ async def delete_role(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.ROLES_DELETE.value,
"You don't have permission to delete roles",
)
result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.id == role_id,
- SearchSpaceRole.search_space_id == search_space_id,
+ select(WorkspaceRole).filter(
+ WorkspaceRole.id == role_id,
+ WorkspaceRole.workspace_id == workspace_id,
)
)
db_role = result.scalars().first()
@@ -471,31 +469,31 @@ async def delete_role(
@router.get(
- "/searchspaces/{search_space_id}/members",
+ "/workspaces/{workspace_id}/members",
response_model=list[MembershipRead],
)
async def list_members(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- List all members of a search space.
+ List all members of a workspace.
Requires MEMBERS_VIEW permission.
"""
try:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.MEMBERS_VIEW.value,
"You don't have permission to view members",
)
result = await session.execute(
- select(SearchSpaceMembership)
- .options(selectinload(SearchSpaceMembership.role))
- .filter(SearchSpaceMembership.search_space_id == search_space_id)
+ select(WorkspaceMembership)
+ .options(selectinload(WorkspaceMembership.role))
+ .filter(WorkspaceMembership.workspace_id == workspace_id)
)
memberships = result.scalars().all()
@@ -510,7 +508,7 @@ async def list_members(
membership_dict = {
"id": membership.id,
"user_id": membership.user_id,
- "search_space_id": membership.search_space_id,
+ "workspace_id": membership.workspace_id,
"role_id": membership.role_id,
"is_owner": membership.is_owner,
"joined_at": membership.joined_at,
@@ -534,11 +532,11 @@ async def list_members(
@router.put(
- "/searchspaces/{search_space_id}/members/{membership_id}",
+ "/workspaces/{workspace_id}/members/{membership_id}",
response_model=MembershipRead,
)
async def update_member_role(
- search_space_id: int,
+ workspace_id: int,
membership_id: int,
membership_update: MembershipUpdate,
session: AsyncSession = Depends(get_async_session),
@@ -553,17 +551,17 @@ async def update_member_role(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.MEMBERS_MANAGE_ROLES.value,
"You don't have permission to manage member roles",
)
result = await session.execute(
- select(SearchSpaceMembership)
- .options(selectinload(SearchSpaceMembership.role))
+ select(WorkspaceMembership)
+ .options(selectinload(WorkspaceMembership.role))
.filter(
- SearchSpaceMembership.id == membership_id,
- SearchSpaceMembership.search_space_id == search_space_id,
+ WorkspaceMembership.id == membership_id,
+ WorkspaceMembership.workspace_id == workspace_id,
)
)
db_membership = result.scalars().first()
@@ -578,18 +576,18 @@ async def update_member_role(
detail="Cannot change the owner's role",
)
- # Verify the new role exists in this search space
+ # Verify the new role exists in this workspace
if membership_update.role_id:
role_result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.id == membership_update.role_id,
- SearchSpaceRole.search_space_id == search_space_id,
+ select(WorkspaceRole).filter(
+ WorkspaceRole.id == membership_update.role_id,
+ WorkspaceRole.workspace_id == workspace_id,
)
)
if not role_result.scalars().first():
raise HTTPException(
status_code=404,
- detail="Role not found in this search space",
+ detail="Role not found in this workspace",
)
db_membership.role_id = membership_update.role_id
@@ -605,7 +603,7 @@ async def update_member_role(
return {
"id": db_membership.id,
"user_id": db_membership.user_id,
- "search_space_id": db_membership.search_space_id,
+ "workspace_id": db_membership.workspace_id,
"role_id": db_membership.role_id,
"is_owner": db_membership.is_owner,
"joined_at": db_membership.joined_at,
@@ -628,22 +626,22 @@ async def update_member_role(
# NOTE: /members/me must be defined BEFORE /members/{membership_id}
# because FastAPI matches routes in order, and "me" would otherwise
# be interpreted as a membership_id (causing a 422 validation error)
-@router.delete("/searchspaces/{search_space_id}/members/me")
-async def leave_search_space(
- search_space_id: int,
+@router.delete("/workspaces/{workspace_id}/members/me")
+async def leave_workspace(
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
- Leave a search space (remove own membership).
- Owners cannot leave their search space.
+ Leave a workspace (remove own membership).
+ Owners cannot leave their workspace.
"""
try:
result = await session.execute(
- select(SearchSpaceMembership).filter(
- SearchSpaceMembership.user_id == user.id,
- SearchSpaceMembership.search_space_id == search_space_id,
+ select(WorkspaceMembership).filter(
+ WorkspaceMembership.user_id == user.id,
+ WorkspaceMembership.workspace_id == workspace_id,
)
)
db_membership = result.scalars().first()
@@ -651,38 +649,38 @@ async def leave_search_space(
if not db_membership:
raise HTTPException(
status_code=404,
- detail="You are not a member of this search space",
+ detail="You are not a member of this workspace",
)
if db_membership.is_owner:
raise HTTPException(
status_code=400,
- detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.",
+ detail="Owners cannot leave their workspace. Transfer ownership first or delete the workspace.",
)
await session.delete(db_membership)
await session.commit()
- return {"message": "Successfully left the search space"}
+ return {"message": "Successfully left the workspace"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
- logger.error(f"Failed to leave search space: {e!s}", exc_info=True)
+ logger.error(f"Failed to leave workspace: {e!s}", exc_info=True)
raise HTTPException(
- status_code=500, detail=f"Failed to leave search space: {e!s}"
+ status_code=500, detail=f"Failed to leave workspace: {e!s}"
) from e
-@router.delete("/searchspaces/{search_space_id}/members/{membership_id}")
+@router.delete("/workspaces/{workspace_id}/members/{membership_id}")
async def remove_member(
- search_space_id: int,
+ workspace_id: int,
membership_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- Remove a member from a search space.
+ Remove a member from a workspace.
Requires MEMBERS_REMOVE permission.
Cannot remove the owner.
"""
@@ -690,15 +688,15 @@ async def remove_member(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.MEMBERS_REMOVE.value,
"You don't have permission to remove members",
)
result = await session.execute(
- select(SearchSpaceMembership).filter(
- SearchSpaceMembership.id == membership_id,
- SearchSpaceMembership.search_space_id == search_space_id,
+ select(WorkspaceMembership).filter(
+ WorkspaceMembership.id == membership_id,
+ WorkspaceMembership.workspace_id == workspace_id,
)
)
db_membership = result.scalars().first()
@@ -709,7 +707,7 @@ async def remove_member(
if db_membership.is_owner:
raise HTTPException(
status_code=400,
- detail="Cannot remove the owner from the search space",
+ detail="Cannot remove the owner from the workspace",
)
await session.delete(db_membership)
@@ -730,25 +728,25 @@ async def remove_member(
@router.post(
- "/searchspaces/{search_space_id}/invites",
+ "/workspaces/{workspace_id}/invites",
response_model=InviteRead,
)
async def create_invite(
- search_space_id: int,
+ workspace_id: int,
invite_data: InviteCreate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
- Create a new invite link for a search space.
+ Create a new invite link for a workspace.
Requires MEMBERS_INVITE permission.
"""
try:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to create invites",
)
@@ -756,21 +754,21 @@ async def create_invite(
# Verify role exists if specified
if invite_data.role_id:
role_result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.id == invite_data.role_id,
- SearchSpaceRole.search_space_id == search_space_id,
+ select(WorkspaceRole).filter(
+ WorkspaceRole.id == invite_data.role_id,
+ WorkspaceRole.workspace_id == workspace_id,
)
)
if not role_result.scalars().first():
raise HTTPException(
status_code=404,
- detail="Role not found in this search space",
+ detail="Role not found in this workspace",
)
- db_invite = SearchSpaceInvite(
+ db_invite = WorkspaceInvite(
**invite_data.model_dump(),
invite_code=generate_invite_code(),
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=user.id,
)
session.add(db_invite)
@@ -778,9 +776,9 @@ async def create_invite(
# Reload with role
result = await session.execute(
- select(SearchSpaceInvite)
- .options(selectinload(SearchSpaceInvite.role))
- .filter(SearchSpaceInvite.id == db_invite.id)
+ select(WorkspaceInvite)
+ .options(selectinload(WorkspaceInvite.role))
+ .filter(WorkspaceInvite.id == db_invite.id)
)
db_invite = result.scalars().first()
@@ -797,31 +795,31 @@ async def create_invite(
@router.get(
- "/searchspaces/{search_space_id}/invites",
+ "/workspaces/{workspace_id}/invites",
response_model=list[InviteRead],
)
async def list_invites(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- List all invites for a search space.
+ List all invites for a workspace.
Requires MEMBERS_INVITE permission.
"""
try:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to view invites",
)
result = await session.execute(
- select(SearchSpaceInvite)
- .options(selectinload(SearchSpaceInvite.role))
- .filter(SearchSpaceInvite.search_space_id == search_space_id)
+ select(WorkspaceInvite)
+ .options(selectinload(WorkspaceInvite.role))
+ .filter(WorkspaceInvite.workspace_id == workspace_id)
)
return result.scalars().all()
@@ -834,11 +832,11 @@ async def list_invites(
@router.put(
- "/searchspaces/{search_space_id}/invites/{invite_id}",
+ "/workspaces/{workspace_id}/invites/{invite_id}",
response_model=InviteRead,
)
async def update_invite(
- search_space_id: int,
+ workspace_id: int,
invite_id: int,
invite_update: InviteUpdate,
session: AsyncSession = Depends(get_async_session),
@@ -852,17 +850,17 @@ async def update_invite(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to update invites",
)
result = await session.execute(
- select(SearchSpaceInvite)
- .options(selectinload(SearchSpaceInvite.role))
+ select(WorkspaceInvite)
+ .options(selectinload(WorkspaceInvite.role))
.filter(
- SearchSpaceInvite.id == invite_id,
- SearchSpaceInvite.search_space_id == search_space_id,
+ WorkspaceInvite.id == invite_id,
+ WorkspaceInvite.workspace_id == workspace_id,
)
)
db_invite = result.scalars().first()
@@ -875,15 +873,15 @@ async def update_invite(
# Verify role exists if updating role_id
if update_data.get("role_id"):
role_result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.id == update_data["role_id"],
- SearchSpaceRole.search_space_id == search_space_id,
+ select(WorkspaceRole).filter(
+ WorkspaceRole.id == update_data["role_id"],
+ WorkspaceRole.workspace_id == workspace_id,
)
)
if not role_result.scalars().first():
raise HTTPException(
status_code=404,
- detail="Role not found in this search space",
+ detail="Role not found in this workspace",
)
for key, value in update_data.items():
@@ -903,9 +901,9 @@ async def update_invite(
) from e
-@router.delete("/searchspaces/{search_space_id}/invites/{invite_id}")
+@router.delete("/workspaces/{workspace_id}/invites/{invite_id}")
async def revoke_invite(
- search_space_id: int,
+ workspace_id: int,
invite_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -918,15 +916,15 @@ async def revoke_invite(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.MEMBERS_INVITE.value,
"You don't have permission to revoke invites",
)
result = await session.execute(
- select(SearchSpaceInvite).filter(
- SearchSpaceInvite.id == invite_id,
- SearchSpaceInvite.search_space_id == search_space_id,
+ select(WorkspaceInvite).filter(
+ WorkspaceInvite.id == invite_id,
+ WorkspaceInvite.workspace_id == workspace_id,
)
)
db_invite = result.scalars().first()
@@ -962,18 +960,18 @@ async def get_invite_info(
"""
try:
result = await session.execute(
- select(SearchSpaceInvite)
+ select(WorkspaceInvite)
.options(
- selectinload(SearchSpaceInvite.role),
- selectinload(SearchSpaceInvite.search_space),
+ selectinload(WorkspaceInvite.role),
+ selectinload(WorkspaceInvite.workspace),
)
- .filter(SearchSpaceInvite.invite_code == invite_code)
+ .filter(WorkspaceInvite.invite_code == invite_code)
)
invite = result.scalars().first()
if not invite:
return InviteInfoResponse(
- search_space_name="",
+ workspace_name="",
role_name=None,
is_valid=False,
message="Invite not found",
@@ -982,9 +980,7 @@ async def get_invite_info(
# Check if invite is still valid
if not invite.is_active:
return InviteInfoResponse(
- search_space_name=invite.search_space.name
- if invite.search_space
- else "",
+ workspace_name=invite.workspace.name if invite.workspace else "",
role_name=invite.role.name if invite.role else None,
is_valid=False,
message="This invite is no longer active",
@@ -992,9 +988,7 @@ async def get_invite_info(
if invite.expires_at and invite.expires_at < datetime.now(UTC):
return InviteInfoResponse(
- search_space_name=invite.search_space.name
- if invite.search_space
- else "",
+ workspace_name=invite.workspace.name if invite.workspace else "",
role_name=invite.role.name if invite.role else None,
is_valid=False,
message="This invite has expired",
@@ -1002,16 +996,14 @@ async def get_invite_info(
if invite.max_uses and invite.uses_count >= invite.max_uses:
return InviteInfoResponse(
- search_space_name=invite.search_space.name
- if invite.search_space
- else "",
+ workspace_name=invite.workspace.name if invite.workspace else "",
role_name=invite.role.name if invite.role else None,
is_valid=False,
message="This invite has reached its maximum uses",
)
return InviteInfoResponse(
- search_space_name=invite.search_space.name if invite.search_space else "",
+ workspace_name=invite.workspace.name if invite.workspace else "",
role_name=invite.role.name if invite.role else "Default",
is_valid=True,
)
@@ -1031,16 +1023,16 @@ async def accept_invite(
):
user = auth.user
"""
- Accept an invite and join a search space.
+ Accept an invite and join a workspace.
"""
try:
result = await session.execute(
- select(SearchSpaceInvite)
+ select(WorkspaceInvite)
.options(
- selectinload(SearchSpaceInvite.role),
- selectinload(SearchSpaceInvite.search_space),
+ selectinload(WorkspaceInvite.role),
+ selectinload(WorkspaceInvite.workspace),
)
- .filter(SearchSpaceInvite.invite_code == request.invite_code)
+ .filter(WorkspaceInvite.invite_code == request.invite_code)
)
invite = result.scalars().first()
@@ -1063,28 +1055,28 @@ async def accept_invite(
# Check if user is already a member
existing_membership = await session.execute(
- select(SearchSpaceMembership).filter(
- SearchSpaceMembership.user_id == user.id,
- SearchSpaceMembership.search_space_id == invite.search_space_id,
+ select(WorkspaceMembership).filter(
+ WorkspaceMembership.user_id == user.id,
+ WorkspaceMembership.workspace_id == invite.workspace_id,
)
)
if existing_membership.scalars().first():
raise HTTPException(
status_code=400,
- detail="You are already a member of this search space",
+ detail="You are already a member of this workspace",
)
# Determine role to assign
role_id = invite.role_id
if not role_id:
# Use default role
- default_role = await get_default_role(session, invite.search_space_id)
+ default_role = await get_default_role(session, invite.workspace_id)
role_id = default_role.id if default_role else None
# Create membership
- membership = SearchSpaceMembership(
+ membership = WorkspaceMembership(
user_id=user.id,
- search_space_id=invite.search_space_id,
+ workspace_id=invite.workspace_id,
role_id=role_id,
is_owner=False,
invited_by_invite_id=invite.id,
@@ -1097,12 +1089,12 @@ async def accept_invite(
await session.commit()
role_name = invite.role.name if invite.role else "Default"
- search_space_name = invite.search_space.name if invite.search_space else ""
+ workspace_name = invite.workspace.name if invite.workspace else ""
return InviteAcceptResponse(
- message="Successfully joined the search space",
- search_space_id=invite.search_space_id,
- search_space_name=search_space_name,
+ message="Successfully joined the workspace",
+ workspace_id=invite.workspace_id,
+ workspace_name=workspace_name,
role_name=role_name,
)
@@ -1120,33 +1112,33 @@ async def accept_invite(
@router.get(
- "/searchspaces/{search_space_id}/my-access",
- response_model=UserSearchSpaceAccess,
+ "/workspaces/{workspace_id}/my-access",
+ response_model=UserWorkspaceAccess,
)
async def get_my_access(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
- Get the current user's access info for a search space.
+ Get the current user's access info for a workspace.
"""
try:
- membership = await check_search_space_access(session, auth, search_space_id)
+ membership = await check_workspace_access(session, auth, workspace_id)
- # Get search space name
+ # Get workspace name
result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == search_space_id)
+ select(Workspace).filter(Workspace.id == workspace_id)
)
- search_space = result.scalars().first()
+ workspace = result.scalars().first()
# Get permissions
- permissions = await get_user_permissions(session, user.id, search_space_id)
+ permissions = await get_user_permissions(session, user.id, workspace_id)
- return UserSearchSpaceAccess(
- search_space_id=search_space_id,
- search_space_name=search_space.name if search_space else "",
+ return UserWorkspaceAccess(
+ workspace_id=workspace_id,
+ workspace_name=workspace.name if workspace else "",
is_owner=membership.is_owner,
role_name=membership.role.name if membership.role else None,
permissions=permissions,
diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py
index bdcf8a874..2fc917b92 100644
--- a/surfsense_backend/app/routes/reports_routes.py
+++ b/surfsense_backend/app/routes/reports_routes.py
@@ -8,7 +8,7 @@ Export is on-demand in multiple formats (PDF, DOCX, HTML, LaTeX, EPUB, ODT,
plain text). PDF uses pypandoc (Markdown->Typst) + typst-py; the rest use
pypandoc directly with format-specific templates and options.
-Authorization: lightweight search-space membership checks (no granular RBAC)
+Authorization: lightweight workspace membership checks (no granular RBAC)
since reports are chat-generated artifacts, not standalone managed resources.
"""
@@ -31,8 +31,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import (
Report,
- SearchSpace,
- SearchSpaceMembership,
+ Workspace,
+ WorkspaceMembership,
get_async_session,
)
from app.schemas import ReportContentRead, ReportContentUpdate, ReportRead
@@ -43,7 +43,7 @@ from app.templates.export_helpers import (
get_typst_template_path,
)
from app.users import get_auth_context
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@@ -160,7 +160,7 @@ async def _get_report_with_access(
session: AsyncSession,
auth: AuthContext,
) -> Report:
- """Fetch a report and verify the user belongs to its search space.
+ """Fetch a report and verify the user belongs to its workspace.
Raises HTTPException(404) if not found, HTTPException(403) if no access.
"""
@@ -171,8 +171,8 @@ async def _get_report_with_access(
raise HTTPException(status_code=404, detail="Report not found")
# Lightweight membership check - no granular RBAC, just "is the user a
- # member of the search space this report belongs to?"
- await check_search_space_access(session, auth, report.search_space_id)
+ # member of the workspace this report belongs to?"
+ await check_workspace_access(session, auth, report.workspace_id)
return report
@@ -204,23 +204,23 @@ async def _get_version_siblings(
async def read_reports(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=MAX_REPORT_LIST_LIMIT),
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
List reports the user has access to.
- Filters by search space membership.
+ Filters by workspace membership.
"""
try:
- if search_space_id is not None:
- # Verify the caller is a member of the requested search space
- await check_search_space_access(session, auth, search_space_id)
+ if workspace_id is not None:
+ # Verify the caller is a member of the requested workspace
+ await check_workspace_access(session, auth, workspace_id)
result = await session.execute(
select(Report)
- .filter(Report.search_space_id == search_space_id)
+ .filter(Report.workspace_id == workspace_id)
.order_by(Report.id.desc())
.offset(skip)
.limit(limit)
@@ -228,9 +228,9 @@ async def read_reports(
else:
result = await session.execute(
select(Report)
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
.order_by(Report.id.desc())
.offset(skip)
.limit(limit)
@@ -307,7 +307,7 @@ async def update_report_content(
"""
Update the Markdown content of a report.
- The caller must be a member of the search space the report belongs to.
+ The caller must be a member of the workspace the report belongs to.
Returns the updated report content including version siblings.
"""
try:
diff --git a/surfsense_backend/app/routes/sandbox_routes.py b/surfsense_backend/app/routes/sandbox_routes.py
index c04abe9ee..8ac0a8b90 100644
--- a/surfsense_backend/app/routes/sandbox_routes.py
+++ b/surfsense_backend/app/routes/sandbox_routes.py
@@ -70,7 +70,7 @@ async def download_sandbox_file(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.CHATS_READ.value,
"You don't have permission to access files in this thread",
)
diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py
index 718b4b907..4cfa78af2 100644
--- a/surfsense_backend/app/routes/search_source_connectors_routes.py
+++ b/surfsense_backend/app/routes/search_source_connectors_routes.py
@@ -1,21 +1,21 @@
"""
SearchSourceConnector routes for CRUD operations:
POST /search-source-connectors/ - Create a new connector
-GET /search-source-connectors/ - List all connectors for the current user (optionally filtered by search space)
+GET /search-source-connectors/ - List all connectors for the current user (optionally filtered by workspace)
GET /search-source-connectors/{connector_id} - Get a specific connector
PUT /search-source-connectors/{connector_id} - Update a specific connector
DELETE /search-source-connectors/{connector_id} - Delete a specific connector
-POST /search-source-connectors/{connector_id}/index - Index content from a connector to a search space
+POST /search-source-connectors/{connector_id}/index - Index content from a connector to a workspace
MCP (Model Context Protocol) Connector routes:
POST /connectors/mcp - Create a new MCP connector with custom API tools
-GET /connectors/mcp - List all MCP connectors for the current user's search space
+GET /connectors/mcp - List all MCP connectors for the current user's workspace
GET /connectors/mcp/{connector_id} - Get a specific MCP connector with tools config
PUT /connectors/mcp/{connector_id} - Update an MCP connector's tools config
DELETE /connectors/mcp/{connector_id} - Delete an MCP connector
-Note: OAuth connectors (Gmail, Drive, Slack, etc.) support multiple accounts per search space.
-Non-OAuth connectors (BookStack, GitHub, etc.) are limited to one per search space.
+Note: OAuth connectors (Gmail, Drive, Slack, etc.) support multiple accounts per workspace.
+Non-OAuth connectors (BookStack, GitHub, etc.) are limited to one per workspace.
"""
import asyncio
@@ -73,6 +73,7 @@ from app.utils.periodic_scheduler import (
update_periodic_schedule,
)
from app.utils.rbac import check_permission
+from app.utils.validators import raise_if_connector_deprecated
# Set up logging
logger = logging.getLogger(__name__)
@@ -170,8 +171,8 @@ async def list_github_repositories(
@router.post("/search-source-connectors", response_model=SearchSourceConnectorRead)
async def create_search_source_connector(
connector: SearchSourceConnectorCreate,
- search_space_id: int = Query(
- ..., description="ID of the search space to associate the connector with"
+ workspace_id: int = Query(
+ ..., description="ID of the workspace to associate the connector with"
),
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
@@ -181,26 +182,32 @@ async def create_search_source_connector(
Create a new search source connector.
Requires CONNECTORS_CREATE permission.
- Each search space can have only one connector of each type (based on search_space_id and connector_type).
+ Each workspace can have only one connector of each type (based on workspace_id and connector_type).
The config must contain the appropriate keys for the connector type.
"""
try:
+ # Refuse new connections for deprecated connector types (HTTP 410). The
+ # search APIs (Tavily/SearXNG/Linkup/Baidu) are created through this
+ # generic route rather than a dedicated OAuth route, so this is the
+ # single choke point that must enforce the deprecation.
+ raise_if_connector_deprecated(connector.connector_type)
+
# Check if user has permission to create connectors
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.CONNECTORS_CREATE.value,
- "You don't have permission to create connectors in this search space",
+ "You don't have permission to create connectors in this workspace",
)
- # Check if a connector with the same type already exists for this search space
+ # Check if a connector with the same type already exists for this workspace
# (for non-OAuth connectors that don't support multiple accounts)
# Exception: MCP_CONNECTOR can have multiple instances with different names
if connector.connector_type != SearchSourceConnectorType.MCP_CONNECTOR:
result = await session.execute(
select(SearchSourceConnector).filter(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.connector_type == connector.connector_type,
)
)
@@ -208,7 +215,7 @@ async def create_search_source_connector(
if existing_connector:
raise HTTPException(
status_code=409,
- detail=f"A connector with type {connector.connector_type} already exists in this search space.",
+ detail=f"A connector with type {connector.connector_type} already exists in this workspace.",
)
# Prepare connector data
@@ -217,7 +224,7 @@ async def create_search_source_connector(
# MCP connectors support multiple instances — ensure unique name
if connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR:
connector_data["name"] = await ensure_unique_connector_name(
- session, connector_data["name"], search_space_id, user.id
+ session, connector_data["name"], workspace_id, user.id
)
# Automatically set next_scheduled_at if periodic indexing is enabled
@@ -231,7 +238,7 @@ async def create_search_source_connector(
)
db_connector = SearchSourceConnector(
- **connector_data, search_space_id=search_space_id, user_id=user.id
+ **connector_data, workspace_id=workspace_id, user_id=user.id
)
session.add(db_connector)
await session.commit()
@@ -244,7 +251,7 @@ async def create_search_source_connector(
):
success = create_periodic_schedule(
connector_id=db_connector.id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=str(user.id),
connector_type=db_connector.connector_type,
frequency_minutes=db_connector.indexing_frequency_minutes,
@@ -263,7 +270,7 @@ async def create_search_source_connector(
await session.rollback()
raise HTTPException(
status_code=409,
- detail=f"Integrity error: A connector with this type already exists in this search space. {e!s}",
+ detail=f"Integrity error: A connector with this type already exists in this workspace. {e!s}",
) from e
except HTTPException:
await session.rollback()
@@ -281,32 +288,32 @@ async def create_search_source_connector(
async def read_search_source_connectors(
skip: int = 0,
limit: int = 100,
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- List all search source connectors for a search space.
+ List all search source connectors for a workspace.
Requires CONNECTORS_READ permission.
"""
try:
- if search_space_id is None:
+ if workspace_id is None:
raise HTTPException(
status_code=400,
- detail="search_space_id is required",
+ detail="workspace_id is required",
)
# Check if user has permission to read connectors
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.CONNECTORS_READ.value,
- "You don't have permission to view connectors in this search space",
+ "You don't have permission to view connectors in this workspace",
)
query = select(SearchSourceConnector).filter(
- SearchSourceConnector.search_space_id == search_space_id
+ SearchSourceConnector.workspace_id == workspace_id
)
result = await session.execute(query.offset(skip).limit(limit))
@@ -348,7 +355,7 @@ async def read_search_source_connector(
await check_permission(
session,
auth,
- connector.search_space_id,
+ connector.workspace_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to view this connector",
)
@@ -390,7 +397,7 @@ async def update_search_source_connector(
await check_permission(
session,
auth,
- db_connector.search_space_id,
+ db_connector.workspace_id,
Permission.CONNECTORS_UPDATE.value,
"You don't have permission to update this connector",
)
@@ -489,8 +496,7 @@ async def update_search_source_connector(
if key == "connector_type" and value != db_connector.connector_type:
check_result = await session.execute(
select(SearchSourceConnector).filter(
- SearchSourceConnector.search_space_id
- == db_connector.search_space_id,
+ SearchSourceConnector.workspace_id == db_connector.workspace_id,
SearchSourceConnector.connector_type == value,
SearchSourceConnector.id != connector_id,
)
@@ -499,7 +505,7 @@ async def update_search_source_connector(
if existing_connector:
raise HTTPException(
status_code=409,
- detail=f"A connector with type {value} already exists in this search space.",
+ detail=f"A connector with type {value} already exists in this workspace.",
)
setattr(db_connector, key, value)
@@ -520,7 +526,7 @@ async def update_search_source_connector(
# Create or update the periodic schedule
success = update_periodic_schedule(
connector_id=db_connector.id,
- search_space_id=db_connector.search_space_id,
+ workspace_id=db_connector.workspace_id,
user_id=str(user.id),
connector_type=db_connector.connector_type,
frequency_minutes=db_connector.indexing_frequency_minutes,
@@ -592,7 +598,7 @@ async def delete_search_source_connector(
await check_permission(
session,
auth,
- db_connector.search_space_id,
+ db_connector.workspace_id,
Permission.CONNECTORS_DELETE.value,
"You don't have permission to delete this connector",
)
@@ -672,7 +678,7 @@ async def delete_search_source_connector(
)
# Delete the connector record
- search_space_id = db_connector.search_space_id
+ workspace_id = db_connector.workspace_id
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
await session.delete(db_connector)
await session.commit()
@@ -682,7 +688,7 @@ async def delete_search_source_connector(
invalidate_mcp_tools_cache,
)
- invalidate_mcp_tools_cache(search_space_id)
+ invalidate_mcp_tools_cache(workspace_id)
logger.info(
f"Connector {connector_id} ({connector_name}) deleted successfully. "
@@ -712,8 +718,8 @@ async def delete_search_source_connector(
)
async def index_connector_content(
connector_id: int,
- search_space_id: int = Query(
- ..., description="ID of the search space to store indexed content"
+ workspace_id: int = Query(
+ ..., description="ID of the workspace to store indexed content"
),
start_date: str = Query(
None,
@@ -732,7 +738,7 @@ async def index_connector_content(
):
user = auth.user
"""
- Index content from a KB connector to a search space.
+ Index content from a KB connector to a workspace.
Live connectors (Slack, Teams, Linear, Jira, ClickUp, Calendar, Airtable,
Gmail, Discord, Luma) use real-time agent tools instead.
@@ -749,25 +755,25 @@ async def index_connector_content(
if not connector:
raise HTTPException(status_code=404, detail="Connector not found")
- # Ensure the connector actually belongs to the requested search space.
+ # Ensure the connector actually belongs to the requested workspace.
# Without this, the permission check below would authorize against the
- # caller-supplied search_space_id (their own space) while the connector
+ # caller-supplied workspace_id (their own space) while the connector
# lives in another user's space, allowing cross-tenant indexing of a
# foreign connector (and use of its stored credentials). Returning 404
# (rather than 403) on a mismatch also avoids disclosing the existence of
- # connectors in other search spaces.
- if connector.search_space_id != search_space_id:
+ # connectors in other workspaces.
+ if connector.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail="Connector not found")
# Check if user has permission to update connectors (indexing is an update
- # operation). Authorize against the connector's OWN search space — matching
+ # operation). Authorize against the connector's OWN workspace — matching
# the read/update/delete handlers — not the client-supplied query param.
await check_permission(
session,
auth,
- connector.search_space_id,
+ connector.workspace_id,
Permission.CONNECTORS_UPDATE.value,
- "You don't have permission to index content in this search space",
+ "You don't have permission to index content in this workspace",
)
# Handle different connector types
@@ -828,7 +834,10 @@ async def index_connector_content(
# For non-calendar connectors, cap at today
indexing_to = end_date if end_date else today_str
- from app.services.mcp_oauth.registry import LIVE_CONNECTOR_TYPES
+ from app.services.mcp_oauth.registry import (
+ DEPRECATED_INDEXING_CONNECTOR_TYPES,
+ LIVE_CONNECTOR_TYPES,
+ )
if connector.connector_type in LIVE_CONNECTOR_TYPES:
return {
@@ -838,7 +847,21 @@ async def index_connector_content(
),
"indexing_started": False,
"connector_id": connector_id,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
+ "indexing_from": indexing_from,
+ "indexing_to": indexing_to,
+ }
+
+ if connector.connector_type in DEPRECATED_INDEXING_CONNECTOR_TYPES:
+ return {
+ "message": (
+ f"Indexing for {connector.connector_type.value} has been "
+ "deprecated. The knowledge base now stores files, notes, and "
+ "uploads only."
+ ),
+ "indexing_started": False,
+ "connector_id": connector_id,
+ "workspace_id": workspace_id,
"indexing_from": indexing_from,
"indexing_to": indexing_to,
}
@@ -847,10 +870,10 @@ async def index_connector_content(
from app.tasks.celery_tasks.connector_tasks import index_notion_pages_task
logger.info(
- f"Triggering Notion indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
+ f"Triggering Notion indexing for connector {connector_id} into workspace {workspace_id} from {indexing_from} to {indexing_to}"
)
index_notion_pages_task.delay(
- connector_id, search_space_id, str(user.id), indexing_from, indexing_to
+ connector_id, workspace_id, str(user.id), indexing_from, indexing_to
)
response_message = "Notion indexing started in the background."
@@ -858,10 +881,10 @@ async def index_connector_content(
from app.tasks.celery_tasks.connector_tasks import index_github_repos_task
logger.info(
- f"Triggering GitHub indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
+ f"Triggering GitHub indexing for connector {connector_id} into workspace {workspace_id} from {indexing_from} to {indexing_to}"
)
index_github_repos_task.delay(
- connector_id, search_space_id, str(user.id), indexing_from, indexing_to
+ connector_id, workspace_id, str(user.id), indexing_from, indexing_to
)
response_message = "GitHub indexing started in the background."
@@ -871,10 +894,10 @@ async def index_connector_content(
)
logger.info(
- f"Triggering Confluence indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
+ f"Triggering Confluence indexing for connector {connector_id} into workspace {workspace_id} from {indexing_from} to {indexing_to}"
)
index_confluence_pages_task.delay(
- connector_id, search_space_id, str(user.id), indexing_from, indexing_to
+ connector_id, workspace_id, str(user.id), indexing_from, indexing_to
)
response_message = "Confluence indexing started in the background."
@@ -884,10 +907,10 @@ async def index_connector_content(
)
logger.info(
- f"Triggering BookStack indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
+ f"Triggering BookStack indexing for connector {connector_id} into workspace {workspace_id} from {indexing_from} to {indexing_to}"
)
index_bookstack_pages_task.delay(
- connector_id, search_space_id, str(user.id), indexing_from, indexing_to
+ connector_id, workspace_id, str(user.id), indexing_from, indexing_to
)
response_message = "BookStack indexing started in the background."
@@ -900,7 +923,7 @@ async def index_connector_content(
if drive_items and drive_items.has_items():
logger.info(
- f"Triggering Google Drive indexing for connector {connector_id} into search space {search_space_id}, "
+ f"Triggering Google Drive indexing for connector {connector_id} into workspace {workspace_id}, "
f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
)
items_dict = drive_items.model_dump()
@@ -929,13 +952,13 @@ async def index_connector_content(
"indexing_options": indexing_options,
}
logger.info(
- f"Triggering Google Drive indexing for connector {connector_id} into search space {search_space_id} "
+ f"Triggering Google Drive indexing for connector {connector_id} into workspace {workspace_id} "
f"using existing config"
)
index_google_drive_files_task.delay(
connector_id,
- search_space_id,
+ workspace_id,
str(user.id),
items_dict,
)
@@ -948,7 +971,7 @@ async def index_connector_content(
if drive_items and drive_items.has_items():
logger.info(
- f"Triggering OneDrive indexing for connector {connector_id} into search space {search_space_id}, "
+ f"Triggering OneDrive indexing for connector {connector_id} into workspace {workspace_id}, "
f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
)
items_dict = drive_items.model_dump()
@@ -976,13 +999,13 @@ async def index_connector_content(
"indexing_options": indexing_options,
}
logger.info(
- f"Triggering OneDrive indexing for connector {connector_id} into search space {search_space_id} "
+ f"Triggering OneDrive indexing for connector {connector_id} into workspace {workspace_id} "
f"using existing config"
)
index_onedrive_files_task.delay(
connector_id,
- search_space_id,
+ workspace_id,
str(user.id),
items_dict,
)
@@ -995,7 +1018,7 @@ async def index_connector_content(
if drive_items and drive_items.has_items():
logger.info(
- f"Triggering Dropbox indexing for connector {connector_id} into search space {search_space_id}, "
+ f"Triggering Dropbox indexing for connector {connector_id} into workspace {workspace_id}, "
f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
)
items_dict = drive_items.model_dump()
@@ -1023,13 +1046,13 @@ async def index_connector_content(
"indexing_options": indexing_options,
}
logger.info(
- f"Triggering Dropbox indexing for connector {connector_id} into search space {search_space_id} "
+ f"Triggering Dropbox indexing for connector {connector_id} into workspace {workspace_id} "
f"using existing config"
)
index_dropbox_files_task.delay(
connector_id,
- search_space_id,
+ workspace_id,
str(user.id),
items_dict,
)
@@ -1044,41 +1067,13 @@ async def index_connector_content(
)
logger.info(
- f"Triggering Elasticsearch indexing for connector {connector_id} into search space {search_space_id}"
+ f"Triggering Elasticsearch indexing for connector {connector_id} into workspace {workspace_id}"
)
index_elasticsearch_documents_task.delay(
- connector_id, search_space_id, str(user.id), indexing_from, indexing_to
+ connector_id, workspace_id, str(user.id), indexing_from, indexing_to
)
response_message = "Elasticsearch indexing started in the background."
- elif connector.connector_type == SearchSourceConnectorType.WEBCRAWLER_CONNECTOR:
- from app.tasks.celery_tasks.connector_tasks import index_crawled_urls_task
- from app.utils.webcrawler_utils import parse_webcrawler_urls
-
- # Check if URLs are configured before triggering indexing
- connector_config = connector.config or {}
- urls = parse_webcrawler_urls(connector_config.get("INITIAL_URLS"))
-
- if not urls:
- # URLs are optional - skip indexing gracefully
- logger.info(
- f"Webcrawler connector {connector_id} has no URLs configured, skipping indexing"
- )
- response_message = "No URLs configured for this connector. Add URLs in the connector settings to enable indexing."
- indexing_started = False
- else:
- logger.info(
- f"Triggering web pages indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
- )
- index_crawled_urls_task.delay(
- connector_id,
- search_space_id,
- str(user.id),
- indexing_from,
- indexing_to,
- )
- response_message = "Web page indexing started in the background."
-
elif (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
@@ -1112,12 +1107,12 @@ async def index_connector_content(
await session.refresh(connector)
logger.info(
- f"Triggering Composio Google Drive indexing for connector {connector_id} into search space {search_space_id}, "
+ f"Triggering Composio Google Drive indexing for connector {connector_id} into workspace {workspace_id}, "
f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
)
else:
logger.info(
- f"Triggering Composio Google Drive indexing for connector {connector_id} into search space {search_space_id} "
+ f"Triggering Composio Google Drive indexing for connector {connector_id} into workspace {workspace_id} "
f"using existing config"
)
@@ -1145,7 +1140,7 @@ async def index_connector_content(
"indexing_options": indexing_options,
}
index_google_drive_files_task.delay(
- connector_id, search_space_id, str(user.id), items_dict
+ connector_id, workspace_id, str(user.id), items_dict
)
response_message = (
"Composio Google Drive indexing started in the background."
@@ -1160,10 +1155,10 @@ async def index_connector_content(
)
logger.info(
- f"Triggering Composio Gmail indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
+ f"Triggering Composio Gmail indexing for connector {connector_id} into workspace {workspace_id} from {indexing_from} to {indexing_to}"
)
index_google_gmail_messages_task.delay(
- connector_id, search_space_id, str(user.id), indexing_from, indexing_to
+ connector_id, workspace_id, str(user.id), indexing_from, indexing_to
)
response_message = "Composio Gmail indexing started in the background."
@@ -1176,10 +1171,10 @@ async def index_connector_content(
)
logger.info(
- f"Triggering Composio Google Calendar indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
+ f"Triggering Composio Google Calendar indexing for connector {connector_id} into workspace {workspace_id} from {indexing_from} to {indexing_to}"
)
index_google_calendar_events_task.delay(
- connector_id, search_space_id, str(user.id), indexing_from, indexing_to
+ connector_id, workspace_id, str(user.id), indexing_from, indexing_to
)
response_message = (
"Composio Google Calendar indexing started in the background."
@@ -1195,7 +1190,7 @@ async def index_connector_content(
"message": response_message,
"indexing_started": indexing_started,
"connector_id": connector_id,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"indexing_from": indexing_from,
"indexing_to": indexing_to,
}
@@ -1292,7 +1287,7 @@ async def _persist_auth_expired(session: AsyncSession, connector_id: int) -> Non
async def _run_indexing_with_notifications(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -1307,7 +1302,7 @@ async def _run_indexing_with_notifications(
Args:
session: Database session
connector_id: ID of the connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -1359,7 +1354,7 @@ async def _run_indexing_with_notifications(
connector_id=connector_id,
connector_name=connector.name,
connector_type=connector.connector_type.value,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
start_date=start_date,
end_date=end_date,
)
@@ -1476,7 +1471,7 @@ async def _run_indexing_with_notifications(
indexing_kwargs = {
"session": session,
"connector_id": connector_id,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"user_id": user_id,
"start_date": start_date,
"end_date": end_date,
@@ -1717,7 +1712,7 @@ async def _run_indexing_with_notifications(
async def run_notion_indexing_with_new_session(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -1732,7 +1727,7 @@ async def run_notion_indexing_with_new_session(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -1746,7 +1741,7 @@ async def run_notion_indexing_with_new_session(
async def run_notion_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -1757,7 +1752,7 @@ async def run_notion_indexing(
Args:
session: Database session
connector_id: ID of the Notion connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -1767,7 +1762,7 @@ async def run_notion_indexing(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -1781,18 +1776,18 @@ async def run_notion_indexing(
# Add new helper functions for GitHub indexing
async def run_github_indexing_with_new_session(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Wrapper to run GitHub indexing with its own database session."""
logger.info(
- f"Background task started: Indexing GitHub connector {connector_id} into space {search_space_id} from {start_date} to {end_date}"
+ f"Background task started: Indexing GitHub connector {connector_id} into space {workspace_id} from {start_date} to {end_date}"
)
async with async_session_maker() as session:
await run_github_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
logger.info(f"Background task finished: Indexing GitHub connector {connector_id}")
@@ -1800,7 +1795,7 @@ async def run_github_indexing_with_new_session(
async def run_github_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -1811,7 +1806,7 @@ async def run_github_indexing(
Args:
session: Database session
connector_id: ID of the GitHub connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -1821,7 +1816,7 @@ async def run_github_indexing(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -1834,18 +1829,18 @@ async def run_github_indexing(
# Add new helper functions for Confluence indexing
async def run_confluence_indexing_with_new_session(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Wrapper to run Confluence indexing with its own database session."""
logger.info(
- f"Background task started: Indexing Confluence connector {connector_id} into space {search_space_id} from {start_date} to {end_date}"
+ f"Background task started: Indexing Confluence connector {connector_id} into space {workspace_id} from {start_date} to {end_date}"
)
async with async_session_maker() as session:
await run_confluence_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
logger.info(
f"Background task finished: Indexing Confluence connector {connector_id}"
@@ -1855,7 +1850,7 @@ async def run_confluence_indexing_with_new_session(
async def run_confluence_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -1866,7 +1861,7 @@ async def run_confluence_indexing(
Args:
session: Database session
connector_id: ID of the Confluence connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -1876,7 +1871,7 @@ async def run_confluence_indexing(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -1889,18 +1884,18 @@ async def run_confluence_indexing(
# Add new helper functions for Google Calendar indexing
async def run_google_calendar_indexing_with_new_session(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Wrapper to run Google Calendar indexing with its own database session."""
logger.info(
- f"Background task started: Indexing Google Calendar connector {connector_id} into space {search_space_id} from {start_date} to {end_date}"
+ f"Background task started: Indexing Google Calendar connector {connector_id} into space {workspace_id} from {start_date} to {end_date}"
)
async with async_session_maker() as session:
await run_google_calendar_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
logger.info(
f"Background task finished: Indexing Google Calendar connector {connector_id}"
@@ -1910,7 +1905,7 @@ async def run_google_calendar_indexing_with_new_session(
async def run_google_calendar_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -1921,7 +1916,7 @@ async def run_google_calendar_indexing(
Args:
session: Database session
connector_id: ID of the Google Calendar connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -1931,7 +1926,7 @@ async def run_google_calendar_indexing(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -1943,7 +1938,7 @@ async def run_google_calendar_indexing(
async def run_google_gmail_indexing_with_new_session(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -1954,14 +1949,14 @@ async def run_google_gmail_indexing_with_new_session(
"""
async with async_session_maker() as session:
await run_google_gmail_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
async def run_google_gmail_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -1972,7 +1967,7 @@ async def run_google_gmail_indexing(
Args:
session: Database session
connector_id: ID of the Google Gmail connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -1983,7 +1978,7 @@ async def run_google_gmail_indexing(
async def gmail_indexing_wrapper(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None,
end_date: str | None,
@@ -1994,7 +1989,7 @@ async def run_google_gmail_indexing(
indexed_count, skipped_count, error_message = await index_google_gmail_messages(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -2007,7 +2002,7 @@ async def run_google_gmail_indexing(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -2020,7 +2015,7 @@ async def run_google_gmail_indexing(
async def run_google_drive_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict, # Dictionary with 'folders', 'files', and 'indexing_options'
):
@@ -2057,7 +2052,7 @@ async def run_google_drive_indexing(
connector_id=connector_id,
connector_name=connector.name,
connector_type=connector.connector_type.value,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
folder_count=len(items.folders),
file_count=len(items.files),
folder_names=items.get_folder_names() if items.folders else None,
@@ -2086,7 +2081,7 @@ async def run_google_drive_indexing(
) = await index_google_drive_files(
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
folder_id=folder.id,
folder_name=folder.name,
@@ -2119,7 +2114,7 @@ async def run_google_drive_indexing(
) = await index_google_drive_selected_files(
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
files=file_tuples,
)
@@ -2199,7 +2194,7 @@ async def run_google_drive_indexing(
async def run_onedrive_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict,
):
@@ -2224,7 +2219,7 @@ async def run_onedrive_indexing(
connector_id=connector_id,
connector_name=connector.name,
connector_type=connector.connector_type.value,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
folder_count=len(items_dict.get("folders", [])),
file_count=len(items_dict.get("files", [])),
folder_names=[
@@ -2251,7 +2246,7 @@ async def run_onedrive_indexing(
) = await index_onedrive_files(
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
items_dict,
)
@@ -2313,7 +2308,7 @@ async def run_onedrive_indexing(
async def run_dropbox_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict,
):
@@ -2338,7 +2333,7 @@ async def run_dropbox_indexing(
connector_id=connector_id,
connector_name=connector.name,
connector_type=connector.connector_type.value,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
folder_count=len(items_dict.get("folders", [])),
file_count=len(items_dict.get("files", [])),
folder_names=[
@@ -2365,7 +2360,7 @@ async def run_dropbox_indexing(
) = await index_dropbox_files(
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
items_dict,
)
@@ -2426,18 +2421,18 @@ async def run_dropbox_indexing(
async def run_elasticsearch_indexing_with_new_session(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Wrapper to run Elasticsearch indexing with its own database session."""
logger.info(
- f"Background task started: Indexing Elasticsearch connector {connector_id} into space {search_space_id}"
+ f"Background task started: Indexing Elasticsearch connector {connector_id} into space {workspace_id}"
)
async with async_session_maker() as session:
await run_elasticsearch_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
logger.info(
f"Background task finished: Indexing Elasticsearch connector {connector_id}"
@@ -2447,7 +2442,7 @@ async def run_elasticsearch_indexing_with_new_session(
async def run_elasticsearch_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -2458,7 +2453,7 @@ async def run_elasticsearch_indexing(
Args:
session: Database session
connector_id: ID of the Elasticsearch connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -2468,7 +2463,7 @@ async def run_elasticsearch_indexing(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -2478,73 +2473,21 @@ async def run_elasticsearch_indexing(
)
-# Add new helper functions for crawled web page indexing
-async def run_web_page_indexing_with_new_session(
- connector_id: int,
- search_space_id: int,
- user_id: str,
- start_date: str,
- end_date: str,
-):
- """
- Create a new session and run the Web page indexing task.
- This prevents session leaks by creating a dedicated session for the background task.
- """
- async with async_session_maker() as session:
- await run_web_page_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
- )
-
-
-async def run_web_page_indexing(
- session: AsyncSession,
- connector_id: int,
- search_space_id: int,
- user_id: str,
- start_date: str,
- end_date: str,
-):
- """
- Background task to run Web page indexing.
-
- Args:
- session: Database session
- connector_id: ID of the webcrawler connector
- search_space_id: ID of the search space
- user_id: ID of the user
- start_date: Start date for indexing
- end_date: End date for indexing
- """
- from app.tasks.connector_indexers import index_crawled_urls
-
- await _run_indexing_with_notifications(
- session=session,
- connector_id=connector_id,
- search_space_id=search_space_id,
- user_id=user_id,
- start_date=start_date,
- end_date=end_date,
- indexing_function=index_crawled_urls,
- update_timestamp_func=_update_connector_timestamp_by_id,
- supports_heartbeat_callback=True,
- )
-
-
# Add new helper functions for BookStack indexing
async def run_bookstack_indexing_with_new_session(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Wrapper to run BookStack indexing with its own database session."""
logger.info(
- f"Background task started: Indexing BookStack connector {connector_id} into space {search_space_id} from {start_date} to {end_date}"
+ f"Background task started: Indexing BookStack connector {connector_id} into space {workspace_id} from {start_date} to {end_date}"
)
async with async_session_maker() as session:
await run_bookstack_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
logger.info(
f"Background task finished: Indexing BookStack connector {connector_id}"
@@ -2554,7 +2497,7 @@ async def run_bookstack_indexing_with_new_session(
async def run_bookstack_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -2565,7 +2508,7 @@ async def run_bookstack_indexing(
Args:
session: Database session
connector_id: ID of the BookStack connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -2575,7 +2518,7 @@ async def run_bookstack_indexing(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -2587,7 +2530,7 @@ async def run_bookstack_indexing(
async def run_composio_indexing_with_new_session(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -2598,14 +2541,14 @@ async def run_composio_indexing_with_new_session(
"""
async with async_session_maker() as session:
await run_composio_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
async def run_composio_indexing(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None,
end_date: str | None,
@@ -2619,7 +2562,7 @@ async def run_composio_indexing(
Args:
session: Database session
connector_id: ID of the Composio connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
@@ -2629,7 +2572,7 @@ async def run_composio_indexing(
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
@@ -2647,7 +2590,7 @@ async def run_composio_indexing(
@router.post("/connectors/mcp", response_model=MCPConnectorRead, status_code=201)
async def create_mcp_connector(
connector_data: MCPConnectorCreate,
- search_space_id: int = Query(..., description="Search space ID"),
+ workspace_id: int = Query(..., description="Workspace ID"),
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
@@ -2660,7 +2603,7 @@ async def create_mcp_connector(
Args:
connector_data: MCP server configuration (command, args, env)
- search_space_id: ID of the search space to attach the connector to
+ workspace_id: ID of the workspace to attach the connector to
session: Database session
user: Current authenticated user
@@ -2668,21 +2611,21 @@ async def create_mcp_connector(
Created MCP connector with server configuration
Raises:
- HTTPException: If search space not found or permission denied
+ HTTPException: If workspace not found or permission denied
"""
try:
# Check user has permission to create connectors
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.CONNECTORS_CREATE.value,
- "You don't have permission to create connectors in this search space",
+ "You don't have permission to create connectors in this workspace",
)
- # Ensure unique name across MCP connectors in this search space
+ # Ensure unique name across MCP connectors in this workspace
unique_name = await ensure_unique_connector_name(
- session, connector_data.name, search_space_id, user.id
+ session, connector_data.name, workspace_id, user.id
)
# Create the connector with single server config
@@ -2693,7 +2636,7 @@ async def create_mcp_connector(
config={"server_config": connector_data.server_config.model_dump()},
periodic_indexing_enabled=False,
indexing_frequency_minutes=None,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user.id,
)
@@ -2703,14 +2646,14 @@ async def create_mcp_connector(
logger.info(
f"Created MCP connector {db_connector.id} "
- f"for user {user.id} in search space {search_space_id}"
+ f"for user {user.id} in workspace {workspace_id}"
)
from app.agents.chat.multi_agent_chat.shared.tools.mcp.cache import (
refresh_mcp_tools_cache_for_connector,
)
- refresh_mcp_tools_cache_for_connector(db_connector.id, search_space_id)
+ refresh_mcp_tools_cache_for_connector(db_connector.id, workspace_id)
connector_read = SearchSourceConnectorRead.model_validate(db_connector)
return MCPConnectorRead.from_connector(connector_read)
@@ -2727,15 +2670,15 @@ async def create_mcp_connector(
@router.get("/connectors/mcp", response_model=list[MCPConnectorRead])
async def list_mcp_connectors(
- search_space_id: int = Query(..., description="Search space ID"),
+ workspace_id: int = Query(..., description="Workspace ID"),
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""
- List all MCP connectors for a search space.
+ List all MCP connectors for a workspace.
Args:
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
session: Database session
user: Current authenticated user
@@ -2747,9 +2690,9 @@ async def list_mcp_connectors(
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.CONNECTORS_READ.value,
- "You don't have permission to view connectors in this search space",
+ "You don't have permission to view connectors in this workspace",
)
# Fetch MCP connectors
@@ -2757,7 +2700,7 @@ async def list_mcp_connectors(
select(SearchSourceConnector).filter(
SearchSourceConnector.connector_type
== SearchSourceConnectorType.MCP_CONNECTOR,
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
)
)
@@ -2811,7 +2754,7 @@ async def get_mcp_connector(
await check_permission(
session,
auth,
- connector.search_space_id,
+ connector.workspace_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to view this connector",
)
@@ -2865,7 +2808,7 @@ async def update_mcp_connector(
await check_permission(
session,
auth,
- connector.search_space_id,
+ connector.workspace_id,
Permission.CONNECTORS_UPDATE.value,
"You don't have permission to update this connector",
)
@@ -2890,7 +2833,7 @@ async def update_mcp_connector(
refresh_mcp_tools_cache_for_connector,
)
- refresh_mcp_tools_cache_for_connector(connector.id, connector.search_space_id)
+ refresh_mcp_tools_cache_for_connector(connector.id, connector.workspace_id)
connector_read = SearchSourceConnectorRead.model_validate(connector)
return MCPConnectorRead.from_connector(connector_read)
@@ -2937,12 +2880,12 @@ async def delete_mcp_connector(
await check_permission(
session,
auth,
- connector.search_space_id,
+ connector.workspace_id,
Permission.CONNECTORS_DELETE.value,
"You don't have permission to delete this connector",
)
- search_space_id = connector.search_space_id
+ workspace_id = connector.workspace_id
await session.delete(connector)
await session.commit()
@@ -2950,7 +2893,7 @@ async def delete_mcp_connector(
invalidate_mcp_tools_cache,
)
- invalidate_mcp_tools_cache(search_space_id)
+ invalidate_mcp_tools_cache(workspace_id)
logger.info(f"Deleted MCP connector {connector_id}")
@@ -3060,7 +3003,7 @@ async def get_drive_picker_token(
await check_permission(
session,
auth,
- connector.search_space_id,
+ connector.workspace_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to access this connector",
)
@@ -3143,7 +3086,7 @@ async def _ensure_mcp_connector_for_user(
The JSONB ``has_key("server_config")`` filter is the same MCP marker
used elsewhere in this module.
- Returns the connector's ``search_space_id`` (needed downstream for
+ Returns the connector's ``workspace_id`` (needed downstream for
MCP tool cache invalidation). Raises ``HTTPException(404)`` when the
connector does not exist, is not owned by the user, or is not
MCP-backed.
@@ -3152,16 +3095,16 @@ async def _ensure_mcp_connector_for_user(
from sqlalchemy.dialects.postgresql import JSONB as PG_JSONB
result = await session.execute(
- select(SearchSourceConnector.search_space_id).where(
+ select(SearchSourceConnector.workspace_id).where(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user_id,
cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"),
)
)
- search_space_id = result.scalar_one_or_none()
- if search_space_id is None:
+ workspace_id = result.scalar_one_or_none()
+ if workspace_id is None:
raise HTTPException(status_code=404, detail="MCP connector not found")
- return search_space_id
+ return workspace_id
@router.post("/connectors/mcp/{connector_id}/trust-tool")
@@ -3185,7 +3128,7 @@ async def trust_mcp_tool(
from app.services.user_tool_allowlist import add_user_trust
try:
- search_space_id = await _ensure_mcp_connector_for_user(
+ workspace_id = await _ensure_mcp_connector_for_user(
session, user_id=user.id, connector_id=connector_id
)
trusted = await add_user_trust(
@@ -3195,7 +3138,7 @@ async def trust_mcp_tool(
tool_name=body.tool_name,
)
await session.commit()
- invalidate_mcp_tools_cache(search_space_id)
+ invalidate_mcp_tools_cache(workspace_id)
return {"status": "ok", "trusted_tools": trusted}
except HTTPException:
@@ -3228,7 +3171,7 @@ async def untrust_mcp_tool(
from app.services.user_tool_allowlist import remove_user_trust
try:
- search_space_id = await _ensure_mcp_connector_for_user(
+ workspace_id = await _ensure_mcp_connector_for_user(
session, user_id=user.id, connector_id=connector_id
)
trusted = await remove_user_trust(
@@ -3238,7 +3181,7 @@ async def untrust_mcp_tool(
tool_name=body.tool_name,
)
await session.commit()
- invalidate_mcp_tools_cache(search_space_id)
+ invalidate_mcp_tools_cache(workspace_id)
return {"status": "ok", "trusted_tools": trusted}
except HTTPException:
diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py
deleted file mode 100644
index 6eebaf201..000000000
--- a/surfsense_backend/app/routes/search_spaces_routes.py
+++ /dev/null
@@ -1,456 +0,0 @@
-import logging
-
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import func
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.future import select
-
-from app.auth.context import AuthContext
-from app.db import (
- Permission,
- SearchSpace,
- SearchSpaceMembership,
- SearchSpaceRole,
- get_async_session,
- get_default_roles_config,
-)
-from app.schemas import (
- SearchSpaceApiAccessUpdate,
- SearchSpaceCreate,
- SearchSpaceRead,
- SearchSpaceUpdate,
- SearchSpaceWithStats,
-)
-from app.users import allow_any_principal, get_auth_context, require_session_context
-from app.utils.rbac import check_permission, check_search_space_access
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter()
-
-
-async def create_default_roles_and_membership(
- session: AsyncSession,
- search_space_id: int,
- owner_user_id,
-) -> None:
- """
- Create default system roles for a search space and add the owner as a member.
-
- Args:
- session: Database session
- search_space_id: The ID of the newly created search space
- owner_user_id: The UUID of the user who created the search space
- """
- # Create default roles
- default_roles = get_default_roles_config()
- owner_role_id = None
-
- for role_config in default_roles:
- db_role = SearchSpaceRole(
- name=role_config["name"],
- description=role_config["description"],
- permissions=role_config["permissions"],
- is_default=role_config["is_default"],
- is_system_role=role_config["is_system_role"],
- search_space_id=search_space_id,
- )
- session.add(db_role)
- await session.flush() # Get the ID
-
- if role_config["name"] == "Owner":
- owner_role_id = db_role.id
-
- # Create owner membership
- owner_membership = SearchSpaceMembership(
- user_id=owner_user_id,
- search_space_id=search_space_id,
- role_id=owner_role_id,
- is_owner=True,
- )
- session.add(owner_membership)
-
-
-@router.post("/searchspaces", response_model=SearchSpaceRead)
-async def create_search_space(
- search_space: SearchSpaceCreate,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(require_session_context),
-):
- user = auth.user
- try:
- search_space_data = search_space.model_dump()
-
- # citations_enabled defaults to True (handled by Pydantic schema)
- # qna_custom_instructions defaults to None/empty (handled by DB)
-
- db_search_space = SearchSpace(**search_space_data, user_id=user.id)
- session.add(db_search_space)
- await session.flush() # Get the search space ID
-
- # Create default roles and owner membership
- await create_default_roles_and_membership(session, db_search_space.id, user.id)
-
- await session.commit()
- await session.refresh(db_search_space)
- return db_search_space
- except HTTPException:
- raise
- except Exception as e:
- await session.rollback()
- logger.error(f"Failed to create search space: {e!s}", exc_info=True)
- raise HTTPException(
- status_code=500, detail=f"Failed to create search space: {e!s}"
- ) from e
-
-
-@router.get("/searchspaces", response_model=list[SearchSpaceWithStats])
-async def read_search_spaces(
- skip: int = 0,
- limit: int = 200,
- owned_only: bool = False,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(allow_any_principal),
-):
- user = auth.user
- """
- Get all search spaces the user has access to, with member count and ownership info.
-
- Args:
- skip: Number of items to skip
- limit: Maximum number of items to return
- owned_only: If True, only return search spaces owned by the user.
- If False (default), return all search spaces the user has access to.
- """
- try:
- # Exclude spaces that are pending background deletion
- not_deleting = ~SearchSpace.name.startswith("[DELETING] ")
-
- api_access_filter = (
- SearchSpace.api_access_enabled == True # noqa: E712
- if auth.is_gated
- else True
- )
-
- if owned_only:
- # Return only search spaces where user is the original creator (user_id)
- result = await session.execute(
- select(SearchSpace)
- .filter(SearchSpace.user_id == user.id, not_deleting, api_access_filter)
- .order_by(SearchSpace.id.asc())
- .offset(skip)
- .limit(limit)
- )
- else:
- # Return all search spaces the user has membership in
- result = await session.execute(
- select(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(
- SearchSpaceMembership.user_id == user.id,
- not_deleting,
- api_access_filter,
- )
- .order_by(SearchSpace.id.asc())
- .offset(skip)
- .limit(limit)
- )
-
- search_spaces = result.scalars().all()
-
- # Get member counts and ownership info for each search space
- search_spaces_with_stats = []
- for space in search_spaces:
- # Get member count
- count_result = await session.execute(
- select(func.count(SearchSpaceMembership.id)).filter(
- SearchSpaceMembership.search_space_id == space.id
- )
- )
- member_count = count_result.scalar() or 1
-
- # Check if current user is owner
- ownership_result = await session.execute(
- select(SearchSpaceMembership).filter(
- SearchSpaceMembership.search_space_id == space.id,
- SearchSpaceMembership.user_id == user.id,
- SearchSpaceMembership.is_owner == True, # noqa: E712
- )
- )
- is_owner = ownership_result.scalars().first() is not None
-
- search_spaces_with_stats.append(
- SearchSpaceWithStats(
- id=space.id,
- name=space.name,
- description=space.description,
- created_at=space.created_at,
- user_id=space.user_id,
- citations_enabled=space.citations_enabled,
- api_access_enabled=space.api_access_enabled,
- qna_custom_instructions=space.qna_custom_instructions,
- ai_file_sort_enabled=space.ai_file_sort_enabled,
- member_count=member_count,
- is_owner=is_owner,
- )
- )
-
- return search_spaces_with_stats
- except Exception as e:
- raise HTTPException(
- status_code=500, detail=f"Failed to fetch search spaces: {e!s}"
- ) from e
-
-
-@router.get("/searchspaces/{search_space_id}", response_model=SearchSpaceRead)
-async def read_search_space(
- search_space_id: int,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(get_auth_context),
-):
- """
- Get a specific search space by ID.
- Requires SETTINGS_VIEW permission or membership.
- """
- try:
- # Check if user has access (is a member)
- await check_search_space_access(session, auth, search_space_id)
-
- result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == search_space_id)
- )
- search_space = result.scalars().first()
-
- if not search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
-
- return search_space
-
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(
- status_code=500, detail=f"Failed to fetch search space: {e!s}"
- ) from e
-
-
-@router.put("/searchspaces/{search_space_id}", response_model=SearchSpaceRead)
-async def update_search_space(
- search_space_id: int,
- search_space_update: SearchSpaceUpdate,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(get_auth_context),
-):
- """
- Update a search space.
- Requires SETTINGS_UPDATE permission.
- """
- try:
- # Check permission
- await check_permission(
- session,
- auth,
- search_space_id,
- Permission.SETTINGS_UPDATE.value,
- "You don't have permission to update this search space",
- )
-
- result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == search_space_id)
- )
- db_search_space = result.scalars().first()
-
- if not db_search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
-
- update_data = search_space_update.model_dump(exclude_unset=True)
-
- for key, value in update_data.items():
- setattr(db_search_space, key, value)
- await session.commit()
- await session.refresh(db_search_space)
- return db_search_space
- except HTTPException:
- raise
- except Exception as e:
- await session.rollback()
- raise HTTPException(
- status_code=500, detail=f"Failed to update search space: {e!s}"
- ) from e
-
-
-@router.put(
- "/searchspaces/{search_space_id}/api-access", response_model=SearchSpaceRead
-)
-async def update_search_space_api_access(
- search_space_id: int,
- body: SearchSpaceApiAccessUpdate,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(get_auth_context),
-):
- """
- Toggle programmatic API/PAT access for a search space.
- Requires API_ACCESS_MANAGE permission.
- """
- try:
- if not auth.is_session:
- raise HTTPException(
- status_code=403,
- detail="This action requires an interactive session",
- )
-
- await check_permission(
- session,
- auth,
- search_space_id,
- Permission.API_ACCESS_MANAGE.value,
- "You don't have permission to manage API access for this search space",
- )
-
- result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == search_space_id)
- )
- db_search_space = result.scalars().first()
-
- if not db_search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
-
- db_search_space.api_access_enabled = body.api_access_enabled
- await session.commit()
- await session.refresh(db_search_space)
- return db_search_space
- except HTTPException:
- raise
- except Exception as e:
- await session.rollback()
- raise HTTPException(
- status_code=500, detail=f"Failed to update API access: {e!s}"
- ) from e
-
-
-@router.post("/searchspaces/{search_space_id}/ai-sort")
-async def trigger_ai_sort(
- search_space_id: int,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(get_auth_context),
-):
- user = auth.user
- """Trigger a full AI file sort for all documents in the search space."""
- try:
- await check_permission(
- session,
- auth,
- search_space_id,
- Permission.SETTINGS_UPDATE.value,
- "You don't have permission to trigger AI sort on this search space",
- )
-
- result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == search_space_id)
- )
- db_search_space = result.scalars().first()
- if not db_search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
-
- from app.tasks.celery_tasks.document_tasks import ai_sort_search_space_task
-
- ai_sort_search_space_task.delay(search_space_id, str(user.id))
- return {"message": "AI sort started"}
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Failed to trigger AI sort: {e!s}", exc_info=True)
- raise HTTPException(
- status_code=500, detail=f"Failed to trigger AI sort: {e!s}"
- ) from e
-
-
-@router.delete("/searchspaces/{search_space_id}", response_model=dict)
-async def delete_search_space(
- search_space_id: int,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(get_auth_context),
-):
- """
- Delete a search space.
- Requires SETTINGS_DELETE permission (only owners have this by default).
-
- Heavy cascade deletion (documents, chunks, threads, etc.) is dispatched
- to Celery so the response is immediate and durable across API restarts.
- """
- try:
- # Check permission - only those with SETTINGS_DELETE can delete
- await check_permission(
- session,
- auth,
- search_space_id,
- Permission.SETTINGS_DELETE.value,
- "You don't have permission to delete this search space",
- )
-
- result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == search_space_id)
- )
- db_search_space = result.scalars().first()
-
- if not db_search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
-
- if (db_search_space.name or "").startswith("[DELETING] "):
- raise HTTPException(
- status_code=409,
- detail="Search space is already being deleted.",
- )
-
- # Soft-delete marker (length-safe for String(100)) so users see pending state.
- prefix = "[DELETING] "
- max_len = 100
- available = max_len - len(prefix)
- base_name = db_search_space.name or ""
- db_search_space.name = f"{prefix}{base_name[:available]}"
- await session.commit()
-
- # Dispatch durable background deletion via Celery.
- # If queue dispatch fails, revert name to avoid stuck "[DELETING]" state.
- try:
- from app.tasks.celery_tasks.document_tasks import delete_search_space_task
-
- delete_search_space_task.delay(search_space_id)
- except Exception as dispatch_error:
- db_search_space.name = base_name
- await session.commit()
- raise HTTPException(
- status_code=503,
- detail="Failed to queue background deletion. Please try again.",
- ) from dispatch_error
-
- return {"message": "Search space deleted successfully"}
- except HTTPException:
- raise
- except Exception as e:
- await session.rollback()
- raise HTTPException(
- status_code=500, detail=f"Failed to delete search space: {e!s}"
- ) from e
-
-
-@router.get("/searchspaces/{search_space_id}/snapshots")
-async def list_search_space_snapshots(
- search_space_id: int,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(get_auth_context),
-):
- """
- List all public chat snapshots for a search space.
-
- Requires PUBLIC_SHARING_VIEW permission.
- """
- from app.schemas.new_chat import PublicChatSnapshotsBySpaceResponse
- from app.services.public_chat_service import list_snapshots_for_search_space
-
- snapshots = await list_snapshots_for_search_space(
- session=session,
- search_space_id=search_space_id,
- auth=auth,
- )
- return PublicChatSnapshotsBySpaceResponse(snapshots=snapshots)
diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py
index ee6f75417..10941dc5b 100644
--- a/surfsense_backend/app/routes/slack_add_connector_route.py
+++ b/surfsense_backend/app/routes/slack_add_connector_route.py
@@ -32,7 +32,7 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
logger = logging.getLogger(__name__)
@@ -87,7 +87,7 @@ async def connect_slack(
Initiate Slack OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -319,7 +319,7 @@ async def slack_callback(
connector_type=SearchSourceConnectorType.SLACK_CONNECTOR,
is_indexable=False,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
session.add(new_connector)
@@ -565,7 +565,7 @@ async def get_slack_channels(
detail="Slack connector not found or access denied",
)
- await check_search_space_access(session, auth, connector.search_space_id)
+ await check_workspace_access(session, auth, connector.workspace_id)
# Get credentials and decrypt bot token
credentials = SlackAuthCredentialsBase.from_dict(connector.config)
diff --git a/surfsense_backend/app/routes/stripe_routes.py b/surfsense_backend/app/routes/stripe_routes.py
index 288e38cc2..459b23d60 100644
--- a/surfsense_backend/app/routes/stripe_routes.py
+++ b/surfsense_backend/app/routes/stripe_routes.py
@@ -65,7 +65,7 @@ def _ensure_credit_buying_enabled() -> None:
)
-def _get_checkout_urls(search_space_id: int) -> tuple[str, str]:
+def _get_checkout_urls(workspace_id: int) -> tuple[str, str]:
if not config.NEXT_FRONTEND_URL:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -79,10 +79,10 @@ def _get_checkout_urls(search_space_id: int) -> tuple[str, str]:
# webhook-vs-redirect race where users land on /purchase-success before
# checkout.session.completed has been delivered.
success_url = (
- f"{base_url}/dashboard/{search_space_id}/purchase-success"
+ f"{base_url}/dashboard/{workspace_id}/purchase-success"
f"?session_id={{CHECKOUT_SESSION_ID}}"
)
- cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
+ cancel_url = f"{base_url}/dashboard/{workspace_id}/purchase-cancel"
return success_url, cancel_url
@@ -471,7 +471,7 @@ async def create_credit_checkout_session(
_ensure_credit_buying_enabled()
stripe_client = get_stripe_client()
price_id = _get_required_credit_price_id()
- success_url, cancel_url = _get_checkout_urls(body.search_space_id)
+ success_url, cancel_url = _get_checkout_urls(body.workspace_id)
credit_micros_granted = body.quantity * config.STRIPE_CREDIT_MICROS_PER_UNIT
try:
@@ -832,11 +832,11 @@ async def create_auto_reload_setup_session(
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
success_url = (
- f"{base_url}/dashboard/{body.search_space_id}/user-settings/purchases"
+ f"{base_url}/dashboard/{body.workspace_id}/user-settings/purchases"
f"?auto_reload_setup=success"
)
cancel_url = (
- f"{base_url}/dashboard/{body.search_space_id}/user-settings/purchases"
+ f"{base_url}/dashboard/{body.workspace_id}/user-settings/purchases"
f"?auto_reload_setup=cancel"
)
diff --git a/surfsense_backend/app/routes/team_memory_routes.py b/surfsense_backend/app/routes/team_memory_routes.py
index 76d934cb2..348ac5b18 100644
--- a/surfsense_backend/app/routes/team_memory_routes.py
+++ b/surfsense_backend/app/routes/team_memory_routes.py
@@ -1,4 +1,4 @@
-"""Routes for search-space team memory."""
+"""Routes for workspace team memory."""
from __future__ import annotations
@@ -17,7 +17,7 @@ from app.services.memory import (
save_memory,
)
from app.users import get_auth_context
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
router = APIRouter()
@@ -26,32 +26,32 @@ class TeamMemoryUpdate(BaseModel):
memory_md: str
-@router.get("/searchspaces/{search_space_id}/memory", response_model=MemoryRead)
+@router.get("/workspaces/{workspace_id}/memory", response_model=MemoryRead)
async def get_team_memory(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
- await check_search_space_access(session, auth, search_space_id)
+ await check_workspace_access(session, auth, workspace_id)
memory_md = await read_memory(
scope=MemoryScope.TEAM,
- target_id=search_space_id,
+ target_id=workspace_id,
session=session,
)
return MemoryRead(memory_md=memory_md, limits=memory_limits())
-@router.put("/searchspaces/{search_space_id}/memory", response_model=MemoryRead)
+@router.put("/workspaces/{workspace_id}/memory", response_model=MemoryRead)
async def update_team_memory(
- search_space_id: int,
+ workspace_id: int,
body: TeamMemoryUpdate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
- await check_search_space_access(session, auth, search_space_id)
+ await check_workspace_access(session, auth, workspace_id)
result = await save_memory(
scope=MemoryScope.TEAM,
- target_id=search_space_id,
+ target_id=workspace_id,
content=body.memory_md,
session=session,
)
@@ -60,16 +60,16 @@ async def update_team_memory(
return MemoryRead(memory_md=result.memory_md, limits=memory_limits())
-@router.post("/searchspaces/{search_space_id}/memory/reset", response_model=MemoryRead)
+@router.post("/workspaces/{workspace_id}/memory/reset", response_model=MemoryRead)
async def reset_team_memory(
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
- await check_search_space_access(session, auth, search_space_id)
+ await check_workspace_access(session, auth, workspace_id)
result = await reset_memory(
scope=MemoryScope.TEAM,
- target_id=search_space_id,
+ target_id=workspace_id,
session=session,
)
if result.status == "error":
diff --git a/surfsense_backend/app/routes/teams_add_connector_route.py b/surfsense_backend/app/routes/teams_add_connector_route.py
index 3782b4720..21c089be0 100644
--- a/surfsense_backend/app/routes/teams_add_connector_route.py
+++ b/surfsense_backend/app/routes/teams_add_connector_route.py
@@ -29,6 +29,7 @@ from app.utils.connector_naming import (
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
+from app.utils.validators import raise_if_connector_deprecated
logger = logging.getLogger(__name__)
@@ -82,7 +83,7 @@ async def connect_teams(
Initiate Microsoft Teams OAuth flow.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user: Current authenticated user
Returns:
@@ -90,6 +91,8 @@ async def connect_teams(
"""
user = auth.user
try:
+ raise_if_connector_deprecated(SearchSourceConnectorType.TEAMS_CONNECTOR)
+
if not space_id:
raise HTTPException(status_code=400, detail="space_id is required")
@@ -327,7 +330,7 @@ async def teams_callback(
connector_type=SearchSourceConnectorType.TEAMS_CONNECTOR,
is_indexable=False,
config=connector_config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user_id,
)
diff --git a/surfsense_backend/app/routes/video_presentations_routes.py b/surfsense_backend/app/routes/video_presentations_routes.py
index e40ccb2f9..e0d3ea78e 100644
--- a/surfsense_backend/app/routes/video_presentations_routes.py
+++ b/surfsense_backend/app/routes/video_presentations_routes.py
@@ -19,9 +19,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import (
Permission,
- SearchSpace,
- SearchSpaceMembership,
VideoPresentation,
+ Workspace,
+ WorkspaceMembership,
get_async_session,
)
from app.schemas import VideoPresentationRead
@@ -35,38 +35,38 @@ router = APIRouter()
async def read_video_presentations(
skip: int = 0,
limit: int = 100,
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""
List video presentations the user has access to.
- Requires VIDEO_PRESENTATIONS_READ permission for the search space(s).
+ Requires VIDEO_PRESENTATIONS_READ permission for the workspace(s).
"""
if skip < 0 or limit < 1:
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
try:
- if search_space_id is not None:
+ if workspace_id is not None:
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
- "You don't have permission to read video presentations in this search space",
+ "You don't have permission to read video presentations in this workspace",
)
result = await session.execute(
select(VideoPresentation)
- .filter(VideoPresentation.search_space_id == search_space_id)
+ .filter(VideoPresentation.workspace_id == workspace_id)
.offset(skip)
.limit(limit)
)
else:
result = await session.execute(
select(VideoPresentation)
- .join(SearchSpace)
- .join(SearchSpaceMembership)
- .filter(SearchSpaceMembership.user_id == user.id)
+ .join(Workspace)
+ .join(WorkspaceMembership)
+ .filter(WorkspaceMembership.user_id == user.id)
.offset(skip)
.limit(limit)
)
@@ -114,9 +114,9 @@ async def read_video_presentation(
await check_permission(
session,
auth,
- video_pres.search_space_id,
+ video_pres.workspace_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
- "You don't have permission to read video presentations in this search space",
+ "You don't have permission to read video presentations in this workspace",
)
return VideoPresentationRead.from_orm_with_slides(video_pres)
@@ -137,7 +137,7 @@ async def delete_video_presentation(
):
"""
Delete a video presentation.
- Requires VIDEO_PRESENTATIONS_DELETE permission for the search space.
+ Requires VIDEO_PRESENTATIONS_DELETE permission for the workspace.
"""
try:
result = await session.execute(
@@ -153,9 +153,9 @@ async def delete_video_presentation(
await check_permission(
session,
auth,
- db_video_pres.search_space_id,
+ db_video_pres.workspace_id,
Permission.VIDEO_PRESENTATIONS_DELETE.value,
- "You don't have permission to delete video presentations in this search space",
+ "You don't have permission to delete video presentations in this workspace",
)
await session.delete(db_video_pres)
@@ -196,9 +196,9 @@ async def stream_slide_audio(
await check_permission(
session,
auth,
- video_pres.search_space_id,
+ video_pres.workspace_id,
Permission.VIDEO_PRESENTATIONS_READ.value,
- "You don't have permission to access video presentations in this search space",
+ "You don't have permission to access video presentations in this workspace",
)
slides = video_pres.slides or []
diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py
new file mode 100644
index 000000000..4f36ef62b
--- /dev/null
+++ b/surfsense_backend/app/routes/workspaces_routes.py
@@ -0,0 +1,416 @@
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.future import select
+
+from app.auth.context import AuthContext
+from app.db import (
+ Permission,
+ Workspace,
+ WorkspaceMembership,
+ WorkspaceRole,
+ get_async_session,
+ get_default_roles_config,
+)
+from app.schemas import (
+ WorkspaceApiAccessUpdate,
+ WorkspaceCreate,
+ WorkspaceRead,
+ WorkspaceUpdate,
+ WorkspaceWithStats,
+)
+from app.users import allow_any_principal, get_auth_context, require_session_context
+from app.utils.rbac import check_permission, check_workspace_access
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+async def create_default_roles_and_membership(
+ session: AsyncSession,
+ workspace_id: int,
+ owner_user_id,
+) -> None:
+ """
+ Create default system roles for a workspace and add the owner as a member.
+
+ Args:
+ session: Database session
+ workspace_id: The ID of the newly created workspace
+ owner_user_id: The UUID of the user who created the workspace
+ """
+ # Create default roles
+ default_roles = get_default_roles_config()
+ owner_role_id = None
+
+ for role_config in default_roles:
+ db_role = WorkspaceRole(
+ name=role_config["name"],
+ description=role_config["description"],
+ permissions=role_config["permissions"],
+ is_default=role_config["is_default"],
+ is_system_role=role_config["is_system_role"],
+ workspace_id=workspace_id,
+ )
+ session.add(db_role)
+ await session.flush() # Get the ID
+
+ if role_config["name"] == "Owner":
+ owner_role_id = db_role.id
+
+ # Create owner membership
+ owner_membership = WorkspaceMembership(
+ user_id=owner_user_id,
+ workspace_id=workspace_id,
+ role_id=owner_role_id,
+ is_owner=True,
+ )
+ session.add(owner_membership)
+
+
+@router.post("/workspaces", response_model=WorkspaceRead)
+async def create_workspace(
+ workspace: WorkspaceCreate,
+ session: AsyncSession = Depends(get_async_session),
+ auth: AuthContext = Depends(require_session_context),
+):
+ user = auth.user
+ try:
+ workspace_data = workspace.model_dump()
+
+ # citations_enabled defaults to True (handled by Pydantic schema)
+ # qna_custom_instructions defaults to None/empty (handled by DB)
+
+ db_workspace = Workspace(**workspace_data, user_id=user.id)
+ session.add(db_workspace)
+ await session.flush() # Get the workspace ID
+
+ # Create default roles and owner membership
+ await create_default_roles_and_membership(session, db_workspace.id, user.id)
+
+ await session.commit()
+ await session.refresh(db_workspace)
+ return db_workspace
+ except HTTPException:
+ raise
+ except Exception as e:
+ await session.rollback()
+ logger.error(f"Failed to create workspace: {e!s}", exc_info=True)
+ raise HTTPException(
+ status_code=500, detail=f"Failed to create workspace: {e!s}"
+ ) from e
+
+
+@router.get("/workspaces", response_model=list[WorkspaceWithStats])
+async def read_workspaces(
+ skip: int = 0,
+ limit: int = 200,
+ owned_only: bool = False,
+ session: AsyncSession = Depends(get_async_session),
+ auth: AuthContext = Depends(allow_any_principal),
+):
+ user = auth.user
+ """
+ Get all workspaces the user has access to, with member count and ownership info.
+
+ Args:
+ skip: Number of items to skip
+ limit: Maximum number of items to return
+ owned_only: If True, only return workspaces owned by the user.
+ If False (default), return all workspaces the user has access to.
+ """
+ try:
+ # Exclude spaces that are pending background deletion
+ not_deleting = ~Workspace.name.startswith("[DELETING] ")
+
+ api_access_filter = (
+ Workspace.api_access_enabled == True # noqa: E712
+ if auth.is_gated
+ else True
+ )
+
+ if owned_only:
+ # Return only workspaces where user is the original creator (user_id)
+ result = await session.execute(
+ select(Workspace)
+ .filter(Workspace.user_id == user.id, not_deleting, api_access_filter)
+ .order_by(Workspace.id.asc())
+ .offset(skip)
+ .limit(limit)
+ )
+ else:
+ # Return all workspaces the user has membership in
+ result = await session.execute(
+ select(Workspace)
+ .join(WorkspaceMembership)
+ .filter(
+ WorkspaceMembership.user_id == user.id,
+ not_deleting,
+ api_access_filter,
+ )
+ .order_by(Workspace.id.asc())
+ .offset(skip)
+ .limit(limit)
+ )
+
+ workspaces = result.scalars().all()
+
+ # Get member counts and ownership info for each workspace
+ workspaces_with_stats = []
+ for space in workspaces:
+ # Get member count
+ count_result = await session.execute(
+ select(func.count(WorkspaceMembership.id)).filter(
+ WorkspaceMembership.workspace_id == space.id
+ )
+ )
+ member_count = count_result.scalar() or 1
+
+ # Check if current user is owner
+ ownership_result = await session.execute(
+ select(WorkspaceMembership).filter(
+ WorkspaceMembership.workspace_id == space.id,
+ WorkspaceMembership.user_id == user.id,
+ WorkspaceMembership.is_owner == True, # noqa: E712
+ )
+ )
+ is_owner = ownership_result.scalars().first() is not None
+
+ workspaces_with_stats.append(
+ WorkspaceWithStats(
+ id=space.id,
+ name=space.name,
+ description=space.description,
+ created_at=space.created_at,
+ user_id=space.user_id,
+ citations_enabled=space.citations_enabled,
+ api_access_enabled=space.api_access_enabled,
+ qna_custom_instructions=space.qna_custom_instructions,
+ member_count=member_count,
+ is_owner=is_owner,
+ )
+ )
+
+ return workspaces_with_stats
+ except Exception as e:
+ raise HTTPException(
+ status_code=500, detail=f"Failed to fetch workspaces: {e!s}"
+ ) from e
+
+
+@router.get("/workspaces/{workspace_id}", response_model=WorkspaceRead)
+async def read_workspace(
+ workspace_id: int,
+ session: AsyncSession = Depends(get_async_session),
+ auth: AuthContext = Depends(get_auth_context),
+):
+ """
+ Get a specific workspace by ID.
+ Requires SETTINGS_VIEW permission or membership.
+ """
+ try:
+ # Check if user has access (is a member)
+ await check_workspace_access(session, auth, workspace_id)
+
+ result = await session.execute(
+ select(Workspace).filter(Workspace.id == workspace_id)
+ )
+ workspace = result.scalars().first()
+
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
+
+ return workspace
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(
+ status_code=500, detail=f"Failed to fetch workspace: {e!s}"
+ ) from e
+
+
+@router.put("/workspaces/{workspace_id}", response_model=WorkspaceRead)
+async def update_workspace(
+ workspace_id: int,
+ workspace_update: WorkspaceUpdate,
+ session: AsyncSession = Depends(get_async_session),
+ auth: AuthContext = Depends(get_auth_context),
+):
+ """
+ Update a workspace.
+ Requires SETTINGS_UPDATE permission.
+ """
+ try:
+ # Check permission
+ await check_permission(
+ session,
+ auth,
+ workspace_id,
+ Permission.SETTINGS_UPDATE.value,
+ "You don't have permission to update this workspace",
+ )
+
+ result = await session.execute(
+ select(Workspace).filter(Workspace.id == workspace_id)
+ )
+ db_workspace = result.scalars().first()
+
+ if not db_workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
+
+ update_data = workspace_update.model_dump(exclude_unset=True)
+
+ for key, value in update_data.items():
+ setattr(db_workspace, key, value)
+ await session.commit()
+ await session.refresh(db_workspace)
+ return db_workspace
+ except HTTPException:
+ raise
+ except Exception as e:
+ await session.rollback()
+ raise HTTPException(
+ status_code=500, detail=f"Failed to update workspace: {e!s}"
+ ) from e
+
+
+@router.put("/workspaces/{workspace_id}/api-access", response_model=WorkspaceRead)
+async def update_workspace_api_access(
+ workspace_id: int,
+ body: WorkspaceApiAccessUpdate,
+ session: AsyncSession = Depends(get_async_session),
+ auth: AuthContext = Depends(get_auth_context),
+):
+ """
+ Toggle programmatic API/PAT access for a workspace.
+ Requires API_ACCESS_MANAGE permission.
+ """
+ try:
+ if not auth.is_session:
+ raise HTTPException(
+ status_code=403,
+ detail="This action requires an interactive session",
+ )
+
+ await check_permission(
+ session,
+ auth,
+ workspace_id,
+ Permission.API_ACCESS_MANAGE.value,
+ "You don't have permission to manage API access for this workspace",
+ )
+
+ result = await session.execute(
+ select(Workspace).filter(Workspace.id == workspace_id)
+ )
+ db_workspace = result.scalars().first()
+
+ if not db_workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
+
+ db_workspace.api_access_enabled = body.api_access_enabled
+ await session.commit()
+ await session.refresh(db_workspace)
+ return db_workspace
+ except HTTPException:
+ raise
+ except Exception as e:
+ await session.rollback()
+ raise HTTPException(
+ status_code=500, detail=f"Failed to update API access: {e!s}"
+ ) from e
+
+
+@router.delete("/workspaces/{workspace_id}", response_model=dict)
+async def delete_workspace(
+ workspace_id: int,
+ session: AsyncSession = Depends(get_async_session),
+ auth: AuthContext = Depends(get_auth_context),
+):
+ """
+ Delete a workspace.
+ Requires SETTINGS_DELETE permission (only owners have this by default).
+
+ Heavy cascade deletion (documents, chunks, threads, etc.) is dispatched
+ to Celery so the response is immediate and durable across API restarts.
+ """
+ try:
+ # Check permission - only those with SETTINGS_DELETE can delete
+ await check_permission(
+ session,
+ auth,
+ workspace_id,
+ Permission.SETTINGS_DELETE.value,
+ "You don't have permission to delete this workspace",
+ )
+
+ result = await session.execute(
+ select(Workspace).filter(Workspace.id == workspace_id)
+ )
+ db_workspace = result.scalars().first()
+
+ if not db_workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
+
+ if (db_workspace.name or "").startswith("[DELETING] "):
+ raise HTTPException(
+ status_code=409,
+ detail="Workspace is already being deleted.",
+ )
+
+ # Soft-delete marker (length-safe for String(100)) so users see pending state.
+ prefix = "[DELETING] "
+ max_len = 100
+ available = max_len - len(prefix)
+ base_name = db_workspace.name or ""
+ db_workspace.name = f"{prefix}{base_name[:available]}"
+ await session.commit()
+
+ # Dispatch durable background deletion via Celery.
+ # If queue dispatch fails, revert name to avoid stuck "[DELETING]" state.
+ try:
+ from app.tasks.celery_tasks.document_tasks import delete_workspace_task
+
+ delete_workspace_task.delay(workspace_id)
+ except Exception as dispatch_error:
+ db_workspace.name = base_name
+ await session.commit()
+ raise HTTPException(
+ status_code=503,
+ detail="Failed to queue background deletion. Please try again.",
+ ) from dispatch_error
+
+ return {"message": "Workspace deleted successfully"}
+ except HTTPException:
+ raise
+ except Exception as e:
+ await session.rollback()
+ raise HTTPException(
+ status_code=500, detail=f"Failed to delete workspace: {e!s}"
+ ) from e
+
+
+@router.get("/workspaces/{workspace_id}/snapshots")
+async def list_workspace_snapshots(
+ workspace_id: int,
+ session: AsyncSession = Depends(get_async_session),
+ auth: AuthContext = Depends(get_auth_context),
+):
+ """
+ List all public chat snapshots for a workspace.
+
+ Requires PUBLIC_SHARING_VIEW permission.
+ """
+ from app.schemas.new_chat import PublicChatSnapshotsBySpaceResponse
+ from app.services.public_chat_service import list_snapshots_for_workspace
+
+ snapshots = await list_snapshots_for_workspace(
+ session=session,
+ workspace_id=workspace_id,
+ auth=auth,
+ )
+ return PublicChatSnapshotsBySpaceResponse(snapshots=snapshots)
diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py
index f111f0226..845136cfc 100644
--- a/surfsense_backend/app/schemas/__init__.py
+++ b/surfsense_backend/app/schemas/__init__.py
@@ -84,7 +84,7 @@ from .rbac_schemas import (
RoleCreate,
RoleRead,
RoleUpdate,
- UserSearchSpaceAccess,
+ UserWorkspaceAccess,
)
from .reports import (
ReportBase,
@@ -103,14 +103,6 @@ from .search_source_connector import (
SearchSourceConnectorRead,
SearchSourceConnectorUpdate,
)
-from .search_space import (
- SearchSpaceApiAccessUpdate,
- SearchSpaceBase,
- SearchSpaceCreate,
- SearchSpaceRead,
- SearchSpaceUpdate,
- SearchSpaceWithStats,
-)
from .stripe import (
CreateCreditCheckoutSessionRequest,
CreateCreditCheckoutSessionResponse,
@@ -128,6 +120,14 @@ from .video_presentations import (
VideoPresentationRead,
VideoPresentationUpdate,
)
+from .workspace import (
+ WorkspaceApiAccessUpdate,
+ WorkspaceBase,
+ WorkspaceCreate,
+ WorkspaceRead,
+ WorkspaceUpdate,
+ WorkspaceWithStats,
+)
__all__ = [
# Folder schemas
@@ -242,13 +242,6 @@ __all__ = [
"SearchSourceConnectorCreate",
"SearchSourceConnectorRead",
"SearchSourceConnectorUpdate",
- "SearchSpaceApiAccessUpdate",
- # Search space schemas
- "SearchSpaceBase",
- "SearchSpaceCreate",
- "SearchSpaceRead",
- "SearchSpaceUpdate",
- "SearchSpaceWithStats",
"StripeWebhookResponse",
"ThreadHistoryLoadResponse",
"ThreadListItem",
@@ -257,12 +250,19 @@ __all__ = [
# User schemas
"UserCreate",
"UserRead",
- "UserSearchSpaceAccess",
"UserUpdate",
+ "UserWorkspaceAccess",
"VerifyConnectionResponse",
# Video Presentation schemas
"VideoPresentationBase",
"VideoPresentationCreate",
"VideoPresentationRead",
"VideoPresentationUpdate",
+ "WorkspaceApiAccessUpdate",
+ # Workspace schemas
+ "WorkspaceBase",
+ "WorkspaceCreate",
+ "WorkspaceRead",
+ "WorkspaceUpdate",
+ "WorkspaceWithStats",
]
diff --git a/surfsense_backend/app/schemas/chat_comments.py b/surfsense_backend/app/schemas/chat_comments.py
index 984e8b812..c9d230a6d 100644
--- a/surfsense_backend/app/schemas/chat_comments.py
+++ b/surfsense_backend/app/schemas/chat_comments.py
@@ -110,8 +110,8 @@ class MentionContextResponse(BaseModel):
thread_id: int
thread_title: str
message_id: int
- search_space_id: int
- search_space_name: str
+ workspace_id: int
+ workspace_name: str
class MentionCommentResponse(BaseModel):
diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py
index 49d2836b2..da0c0e506 100644
--- a/surfsense_backend/app/schemas/documents.py
+++ b/surfsense_backend/app/schemas/documents.py
@@ -30,7 +30,7 @@ class DocumentBase(BaseModel):
content: (
list[ExtensionDocumentContent] | list[str] | str
) # Updated to allow string content
- search_space_id: int
+ workspace_id: int
class DocumentsCreate(DocumentBase):
@@ -59,7 +59,7 @@ class DocumentRead(BaseModel):
unique_identifier_hash: str | None
created_at: datetime
updated_at: datetime | None
- search_space_id: int
+ workspace_id: int
folder_id: int | None = None
created_by_id: UUID | None = None
created_by_name: str | None = None
diff --git a/surfsense_backend/app/schemas/folders.py b/surfsense_backend/app/schemas/folders.py
index a7e065144..9d3444ed4 100644
--- a/surfsense_backend/app/schemas/folders.py
+++ b/surfsense_backend/app/schemas/folders.py
@@ -10,7 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field
class FolderCreate(BaseModel):
name: str = Field(max_length=255, min_length=1)
parent_id: int | None = None
- search_space_id: int
+ workspace_id: int
class FolderUpdate(BaseModel):
@@ -31,7 +31,7 @@ class FolderRead(BaseModel):
name: str
position: str
parent_id: int | None
- search_space_id: int
+ workspace_id: int
created_by_id: UUID | None
created_at: datetime
updated_at: datetime
diff --git a/surfsense_backend/app/schemas/image_generation.py b/surfsense_backend/app/schemas/image_generation.py
index ebd0fa0ac..60307e827 100644
--- a/surfsense_backend/app/schemas/image_generation.py
+++ b/surfsense_backend/app/schemas/image_generation.py
@@ -34,15 +34,15 @@ class ImageGenerationCreate(BaseModel):
size: str | None = Field(None, max_length=50)
style: str | None = Field(None, max_length=50)
response_format: str | None = Field(None, max_length=50)
- search_space_id: int = Field(
- ..., description="Search space ID to associate the generation with"
+ workspace_id: int = Field(
+ ..., description="Workspace ID to associate the generation with"
)
image_gen_model_id: int | None = Field(
None,
description=(
"Image generation model ID. "
"0 = Auto mode, negative = GLOBAL model, positive = BYOK Model row. "
- "If not provided, uses the search space's image_gen_model_id preference."
+ "If not provided, uses the workspace's image_gen_model_id preference."
),
)
@@ -61,7 +61,7 @@ class ImageGenerationRead(BaseModel):
image_gen_model_id: int | None = None
response_data: dict[str, Any] | None = None
error_message: str | None = None
- search_space_id: int
+ workspace_id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@@ -76,7 +76,7 @@ class ImageGenerationListRead(BaseModel):
n: int | None = None
quality: str | None = None
size: str | None = None
- search_space_id: int
+ workspace_id: int
created_at: datetime
is_success: bool
image_count: int | None = None
@@ -99,7 +99,7 @@ class ImageGenerationListRead(BaseModel):
n=obj.n,
quality=obj.quality,
size=obj.size,
- search_space_id=obj.search_space_id,
+ workspace_id=obj.workspace_id,
created_at=obj.created_at,
is_success=obj.response_data is not None,
image_count=image_count,
diff --git a/surfsense_backend/app/schemas/logs.py b/surfsense_backend/app/schemas/logs.py
index a47d5db76..a79aab038 100644
--- a/surfsense_backend/app/schemas/logs.py
+++ b/surfsense_backend/app/schemas/logs.py
@@ -22,7 +22,7 @@ class LogCreate(BaseModel):
message: str
source: str | None = None
log_metadata: dict[str, Any] | None = None
- search_space_id: int
+ workspace_id: int
class LogUpdate(BaseModel):
@@ -36,7 +36,7 @@ class LogUpdate(BaseModel):
class LogRead(LogBase, IDModel, TimestampModel):
id: int
created_at: datetime
- search_space_id: int
+ workspace_id: int
model_config = ConfigDict(from_attributes=True)
@@ -45,7 +45,7 @@ class LogFilter(BaseModel):
level: LogLevel | None = None
status: LogStatus | None = None
source: str | None = None
- search_space_id: int | None = None
+ workspace_id: int | None = None
start_date: datetime | None = None
end_date: datetime | None = None
diff --git a/surfsense_backend/app/schemas/model_connections.py b/surfsense_backend/app/schemas/model_connections.py
index 0eec666c1..7b7ac33ed 100644
--- a/surfsense_backend/app/schemas/model_connections.py
+++ b/surfsense_backend/app/schemas/model_connections.py
@@ -34,7 +34,7 @@ class ConnectionRead(BaseModel):
api_key: str | None = None
extra: dict[str, Any] = Field(default_factory=dict)
scope: ConnectionScope | str
- search_space_id: int | None = None
+ workspace_id: int | None = None
user_id: uuid.UUID | None = None
enabled: bool
has_api_key: bool
@@ -76,7 +76,7 @@ class ConnectionCreate(BaseModel):
api_key: str | None = None
extra: dict[str, Any] = Field(default_factory=dict)
scope: ConnectionScope = ConnectionScope.SEARCH_SPACE
- search_space_id: int | None = None
+ workspace_id: int | None = None
enabled: bool = True
models: list[ModelSelection] = Field(default_factory=list)
diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py
index e486b3dda..356668a7a 100644
--- a/surfsense_backend/app/schemas/new_chat.py
+++ b/surfsense_backend/app/schemas/new_chat.py
@@ -85,7 +85,7 @@ class NewChatThreadBase(BaseModel):
class NewChatThreadCreate(NewChatThreadBase):
"""Schema for creating a new thread."""
- search_space_id: int
+ workspace_id: int
# Visibility defaults to PRIVATE, but can be set on creation
visibility: ChatVisibility = ChatVisibility.PRIVATE
@@ -108,7 +108,7 @@ class NewChatThreadRead(NewChatThreadBase, IDModel):
Schema for reading a thread (matches assistant-ui ThreadRecord).
"""
- search_space_id: int
+ workspace_id: int
visibility: ChatVisibility
created_by_id: UUID | None = None
created_at: datetime
@@ -236,7 +236,7 @@ class NewChatRequest(BaseModel):
chat_id: int
user_query: str
- search_space_id: int
+ workspace_id: int
messages: list[ChatMessage] | None = None # Optional chat history from frontend
mentioned_document_ids: list[int] | None = (
None # Optional document IDs mentioned with @ in the chat
@@ -279,7 +279,7 @@ class NewChatRequest(BaseModel):
default=None,
description=(
"Other chat thread IDs the user @-mentioned. Each is "
- "resolved (access-checked, same search space) into a "
+ "resolved (access-checked, same workspace) into a "
"read-only ```` block prepended to "
"the agent query. Display chips persist via the "
"``mentioned_documents`` list (kind=``thread``)."
@@ -330,7 +330,7 @@ class RegenerateRequest(BaseModel):
``data-revert-results`` and do not abort the regeneration.
"""
- search_space_id: int
+ workspace_id: int
user_query: str | None = (
None # New user query (for edit). None = reload with same query
)
@@ -428,7 +428,7 @@ class ResumeDecision(BaseModel):
class ResumeRequest(BaseModel):
- search_space_id: int
+ workspace_id: int
decisions: list[ResumeDecision]
# Mirrors ``NewChatRequest.disabled_tools`` so the resumed run sees the
# same tool surface as the originating turn.
@@ -520,7 +520,7 @@ class PublicChatSnapshotDetail(BaseModel):
class PublicChatSnapshotsBySpaceResponse(BaseModel):
- """List of public chat snapshots for a search space."""
+ """List of public chat snapshots for a workspace."""
snapshots: list[PublicChatSnapshotDetail]
@@ -556,4 +556,4 @@ class CloneResponse(BaseModel):
"""Response after cloning a public snapshot."""
thread_id: int
- search_space_id: int
+ workspace_id: int
diff --git a/surfsense_backend/app/schemas/obsidian_plugin.py b/surfsense_backend/app/schemas/obsidian_plugin.py
index 89be08c8e..21943a016 100644
--- a/surfsense_backend/app/schemas/obsidian_plugin.py
+++ b/surfsense_backend/app/schemas/obsidian_plugin.py
@@ -150,7 +150,7 @@ class ConnectRequest(_PluginBase):
vault_id: str
vault_name: str
- search_space_id: int
+ workspace_id: int
vault_fingerprint: str = Field(
...,
description=(
@@ -167,7 +167,7 @@ class ConnectResponse(_PluginBase):
connector_id: int
vault_id: str
- search_space_id: int
+ workspace_id: int
capabilities: list[str]
server_time_utc: datetime
diff --git a/surfsense_backend/app/schemas/prompts.py b/surfsense_backend/app/schemas/prompts.py
index 9f11520ff..d744a023f 100644
--- a/surfsense_backend/app/schemas/prompts.py
+++ b/surfsense_backend/app/schemas/prompts.py
@@ -7,7 +7,7 @@ class PromptCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
prompt: str = Field(..., min_length=1)
mode: str = Field(..., pattern="^(transform|explore)$")
- search_space_id: int | None = None
+ workspace_id: int | None = None
is_public: bool = False
@@ -23,7 +23,7 @@ class PromptRead(BaseModel):
name: str
prompt: str
mode: str
- search_space_id: int | None
+ workspace_id: int | None
is_public: bool
version: int
created_at: datetime
diff --git a/surfsense_backend/app/schemas/rbac_schemas.py b/surfsense_backend/app/schemas/rbac_schemas.py
index 8de8426c3..234c4e1a9 100644
--- a/surfsense_backend/app/schemas/rbac_schemas.py
+++ b/surfsense_backend/app/schemas/rbac_schemas.py
@@ -38,7 +38,7 @@ class RoleRead(RoleBase):
"""Schema for reading a role."""
id: int
- search_space_id: int
+ workspace_id: int
is_system_role: bool
created_at: datetime
@@ -66,7 +66,7 @@ class MembershipRead(BaseModel):
id: int
user_id: UUID
- search_space_id: int
+ workspace_id: int
role_id: int | None
is_owner: bool
joined_at: datetime
@@ -123,7 +123,7 @@ class InviteRead(InviteBase):
id: int
invite_code: str
- search_space_id: int
+ workspace_id: int
created_by_id: UUID | None
uses_count: int
is_active: bool
@@ -145,15 +145,15 @@ class InviteAcceptResponse(BaseModel):
"""Response schema for accepting an invite."""
message: str
- search_space_id: int
- search_space_name: str
+ workspace_id: int
+ workspace_name: str
role_name: str | None
class InviteInfoResponse(BaseModel):
"""Response schema for getting invite info (public endpoint)."""
- search_space_name: str
+ workspace_name: str
role_name: str | None
is_valid: bool
message: str | None = None
@@ -180,11 +180,11 @@ class PermissionsListResponse(BaseModel):
# ============ User Access Info ============
-class UserSearchSpaceAccess(BaseModel):
- """Schema for user's access info in a search space."""
+class UserWorkspaceAccess(BaseModel):
+ """Schema for user's access info in a workspace."""
- search_space_id: int
- search_space_name: str
+ workspace_id: int
+ workspace_name: str
is_owner: bool
role_name: str | None
permissions: list[str]
diff --git a/surfsense_backend/app/schemas/reports.py b/surfsense_backend/app/schemas/reports.py
index cfd9d89ca..e7444f6c2 100644
--- a/surfsense_backend/app/schemas/reports.py
+++ b/surfsense_backend/app/schemas/reports.py
@@ -12,7 +12,7 @@ class ReportBase(BaseModel):
title: str
content: str | None = None
report_style: str | None = None
- search_space_id: int
+ workspace_id: int
class ReportRead(BaseModel):
diff --git a/surfsense_backend/app/schemas/search_source_connector.py b/surfsense_backend/app/schemas/search_source_connector.py
index 982931859..b915e8015 100644
--- a/surfsense_backend/app/schemas/search_source_connector.py
+++ b/surfsense_backend/app/schemas/search_source_connector.py
@@ -73,7 +73,7 @@ class SearchSourceConnectorUpdate(BaseModel):
class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel):
- search_space_id: int
+ workspace_id: int
user_id: uuid.UUID
model_config = ConfigDict(from_attributes=True)
@@ -129,7 +129,7 @@ class MCPConnectorRead(BaseModel):
name: str
connector_type: SearchSourceConnectorType
server_config: MCPServerConfig
- search_space_id: int
+ workspace_id: int
user_id: uuid.UUID
created_at: datetime
updated_at: datetime
@@ -148,7 +148,7 @@ class MCPConnectorRead(BaseModel):
name=connector.name,
connector_type=connector.connector_type,
server_config=server_config,
- search_space_id=connector.search_space_id,
+ workspace_id=connector.workspace_id,
user_id=connector.user_id,
created_at=connector.created_at,
updated_at=connector.updated_at,
diff --git a/surfsense_backend/app/schemas/stripe.py b/surfsense_backend/app/schemas/stripe.py
index 95c946a3d..b0b6d25de 100644
--- a/surfsense_backend/app/schemas/stripe.py
+++ b/surfsense_backend/app/schemas/stripe.py
@@ -12,7 +12,7 @@ class CreateCreditCheckoutSessionRequest(BaseModel):
"""Request body for creating a credit-purchase checkout session."""
quantity: int = Field(ge=1, le=10_000)
- search_space_id: int = Field(ge=1)
+ workspace_id: int = Field(ge=1)
class CreateCreditCheckoutSessionResponse(BaseModel):
@@ -114,7 +114,7 @@ class UpdateAutoReloadSettingsRequest(BaseModel):
class CreateAutoReloadSetupSessionRequest(BaseModel):
"""Request body for starting the save-a-card (SetupIntent) checkout."""
- search_space_id: int = Field(ge=1)
+ workspace_id: int = Field(ge=1)
class CreateAutoReloadSetupSessionResponse(BaseModel):
diff --git a/surfsense_backend/app/schemas/video_presentations.py b/surfsense_backend/app/schemas/video_presentations.py
index 68ef3f5ba..f65dee282 100644
--- a/surfsense_backend/app/schemas/video_presentations.py
+++ b/surfsense_backend/app/schemas/video_presentations.py
@@ -20,7 +20,7 @@ class VideoPresentationBase(BaseModel):
title: str
slides: list[dict[str, Any]] | None = None
scene_codes: list[dict[str, Any]] | None = None
- search_space_id: int
+ workspace_id: int
class VideoPresentationCreate(VideoPresentationBase):
@@ -65,7 +65,7 @@ class VideoPresentationRead(VideoPresentationBase):
"title": obj.title,
"slides": slides,
"scene_codes": obj.scene_codes,
- "search_space_id": obj.search_space_id,
+ "workspace_id": obj.workspace_id,
"status": obj.status,
"created_at": obj.created_at,
"slide_count": len(obj.slides) if obj.slides else None,
diff --git a/surfsense_backend/app/schemas/search_space.py b/surfsense_backend/app/schemas/workspace.py
similarity index 64%
rename from surfsense_backend/app/schemas/search_space.py
rename to surfsense_backend/app/schemas/workspace.py
index d74c46716..57b7e0a52 100644
--- a/surfsense_backend/app/schemas/search_space.py
+++ b/surfsense_backend/app/schemas/workspace.py
@@ -6,29 +6,28 @@ from pydantic import BaseModel, ConfigDict
from .base import IDModel, TimestampModel
-class SearchSpaceBase(BaseModel):
+class WorkspaceBase(BaseModel):
name: str
description: str | None = None
-class SearchSpaceCreate(SearchSpaceBase):
+class WorkspaceCreate(WorkspaceBase):
citations_enabled: bool = True
qna_custom_instructions: str | None = None
-class SearchSpaceUpdate(BaseModel):
+class WorkspaceUpdate(BaseModel):
name: str | None = None
description: str | None = None
citations_enabled: bool | None = None
qna_custom_instructions: str | None = None
- ai_file_sort_enabled: bool | None = None
-class SearchSpaceApiAccessUpdate(BaseModel):
+class WorkspaceApiAccessUpdate(BaseModel):
api_access_enabled: bool
-class SearchSpaceRead(SearchSpaceBase, IDModel, TimestampModel):
+class WorkspaceRead(WorkspaceBase, IDModel, TimestampModel):
id: int
created_at: datetime
user_id: uuid.UUID
@@ -36,13 +35,12 @@ class SearchSpaceRead(SearchSpaceBase, IDModel, TimestampModel):
api_access_enabled: bool = False
qna_custom_instructions: str | None = None
shared_memory_md: str | None = None
- ai_file_sort_enabled: bool = False
model_config = ConfigDict(from_attributes=True)
-class SearchSpaceWithStats(SearchSpaceRead):
- """Extended search space info with member count and ownership status."""
+class WorkspaceWithStats(WorkspaceRead):
+ """Extended workspace info with member count and ownership status."""
member_count: int = 1
is_owner: bool = False
diff --git a/surfsense_backend/app/services/ai_file_sort_service.py b/surfsense_backend/app/services/ai_file_sort_service.py
deleted file mode 100644
index 1bf4d325e..000000000
--- a/surfsense_backend/app/services/ai_file_sort_service.py
+++ /dev/null
@@ -1,329 +0,0 @@
-"""AI File Sort Service: builds connector-type/date/category/subcategory folder paths."""
-
-from __future__ import annotations
-
-import json
-import logging
-import re
-from datetime import UTC, datetime
-
-from langchain_core.messages import HumanMessage
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.future import select
-from sqlalchemy.orm import selectinload
-
-from app.db import (
- Chunk,
- Document,
- DocumentType,
- SearchSourceConnector,
- SearchSourceConnectorType,
-)
-from app.services.folder_service import ensure_folder_hierarchy_with_depth_validation
-
-logger = logging.getLogger(__name__)
-
-_DOCTYPE_TO_CONNECTOR_LABEL: dict[str, str] = {
- DocumentType.EXTENSION: "Browser Extension",
- DocumentType.CRAWLED_URL: "Web Crawl",
- DocumentType.FILE: "File Upload",
- DocumentType.SLACK_CONNECTOR: "Slack",
- DocumentType.TEAMS_CONNECTOR: "Teams",
- DocumentType.ONEDRIVE_FILE: "OneDrive",
- DocumentType.NOTION_CONNECTOR: "Notion",
- DocumentType.YOUTUBE_VIDEO: "YouTube",
- DocumentType.GITHUB_CONNECTOR: "GitHub",
- DocumentType.LINEAR_CONNECTOR: "Linear",
- DocumentType.DISCORD_CONNECTOR: "Discord",
- DocumentType.JIRA_CONNECTOR: "Jira",
- DocumentType.CONFLUENCE_CONNECTOR: "Confluence",
- DocumentType.CLICKUP_CONNECTOR: "ClickUp",
- DocumentType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
- DocumentType.GOOGLE_GMAIL_CONNECTOR: "Gmail",
- DocumentType.GOOGLE_DRIVE_FILE: "Google Drive",
- DocumentType.AIRTABLE_CONNECTOR: "Airtable",
- DocumentType.LUMA_CONNECTOR: "Luma",
- DocumentType.ELASTICSEARCH_CONNECTOR: "Elasticsearch",
- DocumentType.BOOKSTACK_CONNECTOR: "BookStack",
- DocumentType.CIRCLEBACK: "Circleback",
- DocumentType.OBSIDIAN_CONNECTOR: "Obsidian",
- DocumentType.NOTE: "Notes",
- DocumentType.DROPBOX_FILE: "Dropbox",
- DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)",
- DocumentType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)",
- DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)",
- DocumentType.LOCAL_FOLDER_FILE: "Local Folder",
-}
-
-_CONNECTOR_TYPE_LABEL: dict[str, str] = {
- SearchSourceConnectorType.SERPER_API: "Serper Search",
- SearchSourceConnectorType.TAVILY_API: "Tavily Search",
- SearchSourceConnectorType.SEARXNG_API: "SearXNG Search",
- SearchSourceConnectorType.LINKUP_API: "Linkup Search",
- SearchSourceConnectorType.BAIDU_SEARCH_API: "Baidu Search",
- SearchSourceConnectorType.SLACK_CONNECTOR: "Slack",
- SearchSourceConnectorType.TEAMS_CONNECTOR: "Teams",
- SearchSourceConnectorType.ONEDRIVE_CONNECTOR: "OneDrive",
- SearchSourceConnectorType.NOTION_CONNECTOR: "Notion",
- SearchSourceConnectorType.GITHUB_CONNECTOR: "GitHub",
- SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear",
- SearchSourceConnectorType.DISCORD_CONNECTOR: "Discord",
- SearchSourceConnectorType.JIRA_CONNECTOR: "Jira",
- SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence",
- SearchSourceConnectorType.CLICKUP_CONNECTOR: "ClickUp",
- SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
- SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: "Gmail",
- SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: "Google Drive",
- SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable",
- SearchSourceConnectorType.LUMA_CONNECTOR: "Luma",
- SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: "Elasticsearch",
- SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: "Web Crawl",
- SearchSourceConnectorType.BOOKSTACK_CONNECTOR: "BookStack",
- SearchSourceConnectorType.CIRCLEBACK_CONNECTOR: "Circleback",
- SearchSourceConnectorType.OBSIDIAN_CONNECTOR: "Obsidian",
- SearchSourceConnectorType.MCP_CONNECTOR: "MCP",
- SearchSourceConnectorType.DROPBOX_CONNECTOR: "Dropbox",
- SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)",
- SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)",
- SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)",
-}
-
-_MAX_CONTENT_CHARS = 4000
-_MAX_CHUNKS_FOR_CONTEXT = 5
-
-_CATEGORY_PROMPT = (
- "Based on the document information below, classify it into a broad category "
- "and a more specific subcategory.\n\n"
- "Rules:\n"
- "- category: 1-2 word broad theme (e.g. Science, Finance, Engineering, Communication, Media)\n"
- "- subcategory: 1-2 word specific topic within the category "
- "(e.g. Physics, Tax Reports, Backend, Team Updates)\n"
- "- Use nouns only. Do not include generic terms like 'General' or 'Miscellaneous'.\n\n"
- "Title: {title}\n\n"
- "Content: {summary}\n\n"
- 'Respond with ONLY a JSON object: {{"category": "...", "subcategory": "..."}}'
-)
-
-_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9 _\-()]")
-_FALLBACK_CATEGORY = "Uncategorized"
-_FALLBACK_SUBCATEGORY = "General"
-
-
-def resolve_root_folder_label(
- document: Document, connector: SearchSourceConnector | None
-) -> str:
- if connector is not None:
- return _CONNECTOR_TYPE_LABEL.get(
- connector.connector_type, str(connector.connector_type)
- )
- return _DOCTYPE_TO_CONNECTOR_LABEL.get(
- document.document_type, str(document.document_type)
- )
-
-
-def resolve_date_folder(document: Document) -> str:
- ts = document.updated_at or document.created_at
- if ts is None:
- ts = datetime.now(UTC)
- return ts.strftime("%Y-%m-%d")
-
-
-def sanitize_category_folder_name(
- value: str | None, fallback: str = _FALLBACK_CATEGORY
-) -> str:
- if not value or not value.strip():
- return fallback
- cleaned = _SAFE_NAME_RE.sub("", value.strip())
- cleaned = " ".join(cleaned.split())
- if not cleaned:
- return fallback
- return cleaned[:50]
-
-
-async def _resolve_document_text(
- session: AsyncSession,
- document: Document,
-) -> str:
- """Build the best available text representation for taxonomy generation.
-
- Prefers ``document.content``; falls back to joining the first few chunks
- when content is empty or too short to be useful.
- """
- text = (document.content or "").strip()
- if len(text) >= 100:
- return text[:_MAX_CONTENT_CHARS]
-
- stmt = (
- select(Chunk.content)
- .where(Chunk.document_id == document.id)
- .order_by(Chunk.position, Chunk.id)
- .limit(_MAX_CHUNKS_FOR_CONTEXT)
- )
- result = await session.execute(stmt)
- chunk_texts = [row[0] for row in result.all() if row[0]]
- if chunk_texts:
- combined = "\n\n".join(chunk_texts)
- return combined[:_MAX_CONTENT_CHARS]
-
- return text[:_MAX_CONTENT_CHARS]
-
-
-def _get_cached_taxonomy(document: Document) -> tuple[str, str] | None:
- """Return (category, subcategory) from document metadata cache, or None."""
- meta = document.document_metadata
- if not isinstance(meta, dict):
- return None
- cat = meta.get("ai_sort_category")
- subcat = meta.get("ai_sort_subcategory")
- if cat and subcat and isinstance(cat, str) and isinstance(subcat, str):
- return cat, subcat
- return None
-
-
-def _set_cached_taxonomy(document: Document, category: str, subcategory: str) -> None:
- """Persist the AI taxonomy on document metadata for deterministic re-sorts."""
- meta = dict(document.document_metadata or {})
- meta["ai_sort_category"] = category
- meta["ai_sort_subcategory"] = subcategory
- document.document_metadata = meta
-
-
-async def generate_ai_taxonomy(
- title: str,
- summary_or_content: str,
- llm,
-) -> tuple[str, str]:
- """Return (category, subcategory) using a single structured LLM call."""
- text = (summary_or_content or "").strip()
- if not text:
- return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY
-
- if len(text) > _MAX_CONTENT_CHARS:
- text = text[:_MAX_CONTENT_CHARS]
-
- prompt = _CATEGORY_PROMPT.format(title=title or "Untitled", summary=text)
- try:
- result = await llm.ainvoke([HumanMessage(content=prompt)])
- raw = result.content.strip()
- if raw.startswith("```"):
- raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
- parsed = json.loads(raw)
- category = sanitize_category_folder_name(
- parsed.get("category"), _FALLBACK_CATEGORY
- )
- subcategory = sanitize_category_folder_name(
- parsed.get("subcategory"), _FALLBACK_SUBCATEGORY
- )
- return category, subcategory
- except Exception:
- logger.warning("AI taxonomy generation failed, using fallback", exc_info=True)
- return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY
-
-
-def _build_path_segments(
- root_label: str,
- date_label: str,
- category: str,
- subcategory: str,
-) -> list[dict]:
- return [
- {"name": root_label, "metadata": {"ai_sort": True, "ai_sort_level": 1}},
- {"name": date_label, "metadata": {"ai_sort": True, "ai_sort_level": 2}},
- {"name": category, "metadata": {"ai_sort": True, "ai_sort_level": 3}},
- {"name": subcategory, "metadata": {"ai_sort": True, "ai_sort_level": 4}},
- ]
-
-
-async def _resolve_taxonomy(
- session: AsyncSession,
- document: Document,
- llm,
-) -> tuple[str, str]:
- """Return (category, subcategory), reusing cached values when available."""
- cached = _get_cached_taxonomy(document)
- if cached is not None:
- return cached
-
- content_text = await _resolve_document_text(session, document)
- category, subcategory = await generate_ai_taxonomy(
- document.title, content_text, llm
- )
- _set_cached_taxonomy(document, category, subcategory)
- return category, subcategory
-
-
-async def ai_sort_document(
- session: AsyncSession,
- document: Document,
- llm,
-) -> Document:
- """Sort a single document into the 4-level AI folder hierarchy."""
- connector: SearchSourceConnector | None = None
- if document.connector_id is not None:
- connector = await session.get(SearchSourceConnector, document.connector_id)
-
- root_label = resolve_root_folder_label(document, connector)
- date_label = resolve_date_folder(document)
-
- category, subcategory = await _resolve_taxonomy(session, document, llm)
-
- segments = _build_path_segments(root_label, date_label, category, subcategory)
-
- leaf_folder = await ensure_folder_hierarchy_with_depth_validation(
- session,
- document.search_space_id,
- segments,
- )
-
- document.folder_id = leaf_folder.id
- await session.flush()
- return document
-
-
-async def ai_sort_all_documents(
- session: AsyncSession,
- search_space_id: int,
- llm,
-) -> tuple[int, int]:
- """Sort all documents in a search space. Returns (sorted_count, failed_count)."""
- stmt = (
- select(Document)
- .where(Document.search_space_id == search_space_id)
- .options(selectinload(Document.connector))
- )
- result = await session.execute(stmt)
- documents = list(result.scalars().all())
-
- sorted_count = 0
- failed_count = 0
-
- for doc in documents:
- try:
- connector = doc.connector
- root_label = resolve_root_folder_label(doc, connector)
- date_label = resolve_date_folder(doc)
-
- category, subcategory = await _resolve_taxonomy(session, doc, llm)
- segments = _build_path_segments(
- root_label, date_label, category, subcategory
- )
-
- leaf_folder = await ensure_folder_hierarchy_with_depth_validation(
- session,
- search_space_id,
- segments,
- )
- doc.folder_id = leaf_folder.id
- sorted_count += 1
- except Exception:
- logger.error("Failed to AI-sort document %s", doc.id, exc_info=True)
- failed_count += 1
-
- await session.commit()
- logger.info(
- "AI sort complete for search_space=%d: sorted=%d, failed=%d",
- search_space_id,
- sorted_count,
- failed_count,
- )
- return sorted_count, failed_count
diff --git a/surfsense_backend/app/services/auto_model_pin_service.py b/surfsense_backend/app/services/auto_model_pin_service.py
index f98933a65..08a64d0d4 100644
--- a/surfsense_backend/app/services/auto_model_pin_service.py
+++ b/surfsense_backend/app/services/auto_model_pin_service.py
@@ -7,7 +7,7 @@ subsequent turns are stable.
Single-writer invariant: this module is the only writer of
``NewChatThread.pinned_llm_config_id`` (aside from the bulk clear in
-``model_connections_routes`` when a search space's ``chat_model_id`` changes).
+``model_connections_routes`` when a workspace's ``chat_model_id`` changes).
Therefore a non-NULL value unambiguously means "this thread has an
Auto-resolved pin"; no separate source/policy column is needed.
"""
@@ -366,7 +366,7 @@ def _global_candidates(
async def _db_candidates(
session: AsyncSession,
*,
- search_space_id: int,
+ workspace_id: int,
user_id: str | UUID | None,
capability: str,
requires_image_input: bool = False,
@@ -388,7 +388,7 @@ async def _db_candidates(
conn = model.connection
if not conn:
continue
- if conn.search_space_id is not None and conn.search_space_id != search_space_id:
+ if conn.workspace_id is not None and conn.workspace_id != workspace_id:
continue
if (
conn.user_id is not None
@@ -430,7 +430,7 @@ async def _db_candidates(
async def auto_model_candidates(
session: AsyncSession,
*,
- search_space_id: int,
+ workspace_id: int,
user_id: str | UUID | None,
capability: str,
requires_image_input: bool = False,
@@ -445,7 +445,7 @@ async def auto_model_candidates(
shared_global_cooled_down_ids = _shared_runtime_cooled_down_ids(global_ids)
db_candidates = await _db_candidates(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
capability=capability,
requires_image_input=requires_image_input,
@@ -517,7 +517,7 @@ async def resolve_or_get_pinned_llm_config_id(
session: AsyncSession,
*,
thread_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str | UUID | None,
selected_llm_config_id: int,
force_repin_free: bool = False,
@@ -550,9 +550,9 @@ async def resolve_or_get_pinned_llm_config_id(
)
if thread is None:
raise ValueError(f"Thread {thread_id} not found")
- if thread.search_space_id != search_space_id:
+ if thread.workspace_id != workspace_id:
raise ValueError(
- f"Thread {thread_id} does not belong to search space {search_space_id}"
+ f"Thread {thread_id} does not belong to workspace {workspace_id}"
)
# Explicit model selected: clear any stale pin.
@@ -569,7 +569,7 @@ async def resolve_or_get_pinned_llm_config_id(
excluded_ids = {int(cid) for cid in (exclude_config_ids or set())}
candidates = await auto_model_candidates(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
capability="chat",
requires_image_input=requires_image_input,
@@ -595,9 +595,9 @@ async def resolve_or_get_pinned_llm_config_id(
):
pinned_cfg = candidate_by_id[int(pinned_id)]
logger.info(
- "auto_pin_reused thread_id=%s search_space_id=%s resolved_config_id=%s tier=%s",
+ "auto_pin_reused thread_id=%s workspace_id=%s resolved_config_id=%s tier=%s",
thread_id,
- search_space_id,
+ workspace_id,
pinned_id,
_tier_of(pinned_cfg),
)
@@ -622,16 +622,16 @@ async def resolve_or_get_pinned_llm_config_id(
# the user's image attachment instead of suspecting a cooldown.
if requires_image_input:
logger.info(
- "auto_pin_repinned_for_image thread_id=%s search_space_id=%s "
+ "auto_pin_repinned_for_image thread_id=%s workspace_id=%s "
"previous_config_id=%s",
thread_id,
- search_space_id,
+ workspace_id,
pinned_id,
)
logger.info(
- "auto_pin_invalid thread_id=%s search_space_id=%s pinned_config_id=%s",
+ "auto_pin_invalid thread_id=%s workspace_id=%s pinned_config_id=%s",
thread_id,
- search_space_id,
+ workspace_id,
pinned_id,
)
@@ -663,27 +663,27 @@ async def resolve_or_get_pinned_llm_config_id(
if force_repin_free:
logger.info(
- "auto_pin_forced_free_repin thread_id=%s search_space_id=%s previous_config_id=%s resolved_config_id=%s",
+ "auto_pin_forced_free_repin thread_id=%s workspace_id=%s previous_config_id=%s resolved_config_id=%s",
thread_id,
- search_space_id,
+ workspace_id,
pinned_id,
selected_id,
)
if pinned_id is None:
logger.info(
- "auto_pin_created thread_id=%s search_space_id=%s resolved_config_id=%s tier=%s premium_eligible=%s",
+ "auto_pin_created thread_id=%s workspace_id=%s resolved_config_id=%s tier=%s premium_eligible=%s",
thread_id,
- search_space_id,
+ workspace_id,
selected_id,
selected_tier,
premium_eligible,
)
else:
logger.info(
- "auto_pin_repaired thread_id=%s search_space_id=%s previous_config_id=%s resolved_config_id=%s tier=%s premium_eligible=%s",
+ "auto_pin_repaired thread_id=%s workspace_id=%s previous_config_id=%s resolved_config_id=%s tier=%s premium_eligible=%s",
thread_id,
- search_space_id,
+ workspace_id,
pinned_id,
selected_id,
selected_tier,
diff --git a/surfsense_backend/app/services/billable_calls.py b/surfsense_backend/app/services/billable_calls.py
index 15a3c3e55..5c6f5dd76 100644
--- a/surfsense_backend/app/services/billable_calls.py
+++ b/surfsense_backend/app/services/billable_calls.py
@@ -117,7 +117,7 @@ async def _record_audit_best_effort(
*,
session_factory: BillableSessionFactory,
usage_type: str,
- search_space_id: int,
+ workspace_id: int,
user_id: UUID,
prompt_tokens: int,
completion_tokens: int,
@@ -156,7 +156,7 @@ async def _record_audit_best_effort(
await record_token_usage(
audit_session,
usage_type=usage_type,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
@@ -217,7 +217,7 @@ async def _record_audit_best_effort(
async def billable_call(
*,
user_id: UUID,
- search_space_id: int,
+ workspace_id: int,
billing_tier: str,
base_model: str,
quota_reserve_tokens: int | None = None,
@@ -233,9 +233,9 @@ async def billable_call(
Args:
user_id: Owner of the credit pool to debit. For vision-LLM during
- indexing this is the *search-space owner* (issue M), not the
+ indexing this is the *workspace owner* (issue M), not the
triggering user.
- search_space_id: Required — recorded on the ``TokenUsage`` audit row.
+ workspace_id: Required — recorded on the ``TokenUsage`` audit row.
billing_tier: ``"premium"`` debits; anything else (``"free"``) skips
the reserve/finalize dance but still records an audit row with
the captured cost.
@@ -281,7 +281,7 @@ async def billable_call(
await _record_audit_best_effort(
session_factory=session_factory,
usage_type=usage_type,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
prompt_tokens=acc.total_prompt_tokens,
completion_tokens=acc.total_completion_tokens,
@@ -423,7 +423,7 @@ async def billable_call(
await _record_audit_best_effort(
session_factory=session_factory,
usage_type=usage_type,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
prompt_tokens=acc.total_prompt_tokens,
completion_tokens=acc.total_completion_tokens,
@@ -438,21 +438,21 @@ async def billable_call(
)
-async def _resolve_agent_billing_for_search_space(
+async def _resolve_agent_billing_for_workspace(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
*,
thread_id: int | None = None,
) -> tuple[UUID, str, str]:
- """Resolve ``(owner_user_id, billing_tier, base_model)`` for the search-space
+ """Resolve ``(owner_user_id, billing_tier, base_model)`` for the workspace
chat model.
Used by Celery tasks (podcast generation, video presentation) to bill the
- search-space owner's premium credit pool when the chat model is premium.
+ workspace owner's premium credit pool when the chat model is premium.
Resolution rules mirror the chat model role resolver:
- - Search space not found / no ``chat_model_id``: raise ``ValueError``.
+ - Workspace not found / no ``chat_model_id``: raise ``ValueError``.
- **Auto mode** (``id == AUTO_MODE_ID == 0``):
* ``thread_id`` is set: delegate to
``resolve_or_get_pinned_llm_config_id`` (the same call chat uses) and
@@ -481,22 +481,20 @@ async def _resolve_agent_billing_for_search_space(
from sqlalchemy import select
from sqlalchemy.orm import selectinload
- from app.db import Model, SearchSpace
+ from app.db import Model, Workspace
result = await session.execute(
- select(SearchSpace).where(SearchSpace.id == search_space_id)
+ select(Workspace).where(Workspace.id == workspace_id)
)
- search_space = result.scalars().first()
- if search_space is None:
- raise ValueError(f"Search space {search_space_id} not found")
+ workspace = result.scalars().first()
+ if workspace is None:
+ raise ValueError(f"Workspace {workspace_id} not found")
- chat_model_id = search_space.chat_model_id
+ chat_model_id = workspace.chat_model_id
if chat_model_id is None:
- raise ValueError(
- f"Search space {search_space_id} has no chat_model_id configured"
- )
+ raise ValueError(f"Workspace {workspace_id} has no chat_model_id configured")
- owner_user_id: UUID = search_space.user_id
+ owner_user_id: UUID = workspace.user_id
from app.services.auto_model_pin_service import (
AUTO_MODE_ID,
@@ -510,15 +508,15 @@ async def _resolve_agent_billing_for_search_space(
resolution = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=str(owner_user_id),
selected_llm_config_id=AUTO_MODE_ID,
)
except ValueError:
logger.warning(
"[agent_billing] Auto-mode pin resolution failed for "
- "search_space=%s thread=%s; falling back to free",
- search_space_id,
+ "workspace=%s thread=%s; falling back to free",
+ workspace_id,
thread_id,
exc_info=True,
)
@@ -546,7 +544,7 @@ async def _resolve_agent_billing_for_search_space(
and model.connection is not None
and model.connection.enabled
and (
- model.connection.search_space_id in (None, search_space_id)
+ model.connection.workspace_id in (None, workspace_id)
and model.connection.user_id in (None, owner_user_id)
)
):
@@ -558,7 +556,7 @@ async def _resolve_agent_billing_for_search_space(
__all__ = [
"BillingSettlementError",
"QuotaInsufficientError",
- "_resolve_agent_billing_for_search_space",
+ "_resolve_agent_billing_for_workspace",
"billable_call",
]
diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py
index b44f6f37c..2dcad48b9 100644
--- a/surfsense_backend/app/services/chat_comments_service.py
+++ b/surfsense_backend/app/services/chat_comments_service.py
@@ -17,8 +17,8 @@ from app.db import (
NewChatMessageRole,
NewChatThread,
Permission,
- SearchSpaceMembership,
User,
+ WorkspaceMembership,
has_permission,
)
from app.notifications.service import NotificationService
@@ -64,7 +64,7 @@ async def process_mentions(
session: AsyncSession,
comment_id: int,
content: str,
- search_space_id: int,
+ workspace_id: int,
) -> dict[UUID, int]:
"""
Parse mentions from content, validate users are members, and insert mention records.
@@ -73,7 +73,7 @@ async def process_mentions(
session: Database session
comment_id: ID of the comment containing mentions
content: Comment text with @[uuid] mentions
- search_space_id: ID of the search space for membership validation
+ workspace_id: ID of the workspace for membership validation
Returns:
Dictionary mapping mentioned user UUID to their mention record ID
@@ -84,9 +84,9 @@ async def process_mentions(
# Get valid members from the mentioned UUIDs
result = await session.execute(
- select(SearchSpaceMembership.user_id).filter(
- SearchSpaceMembership.search_space_id == search_space_id,
- SearchSpaceMembership.user_id.in_(mentioned_uuids),
+ select(WorkspaceMembership.user_id).filter(
+ WorkspaceMembership.workspace_id == workspace_id,
+ WorkspaceMembership.user_id.in_(mentioned_uuids),
)
)
valid_member_ids = result.scalars().all()
@@ -166,19 +166,19 @@ async def get_comments_for_message(
if not message:
raise HTTPException(status_code=404, detail="Message not found")
- search_space_id = message.thread.search_space_id
+ workspace_id = message.thread.workspace_id
# Check permission to read comments
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.COMMENTS_READ.value,
- "You don't have permission to read comments in this search space",
+ "You don't have permission to read comments in this workspace",
)
# Get user permissions for can_delete computation
- user_permissions = await get_user_permissions(session, user.id, search_space_id)
+ user_permissions = await get_user_permissions(session, user.id, workspace_id)
can_delete_any = has_permission(user_permissions, Permission.COMMENTS_DELETE.value)
# Get top-level comments (parent_id IS NULL) with their authors and replies
@@ -276,7 +276,7 @@ async def get_comments_for_messages_batch(
"""
Batch-fetch comments for multiple messages in a single DB round-trip.
- Validates that all messages exist and belong to search spaces the user
+ Validates that all messages exist and belong to workspaces the user
can read comments in, then loads all comments with eager-loaded authors
and replies.
"""
@@ -293,15 +293,15 @@ async def get_comments_for_messages_batch(
messages = result.scalars().all()
msg_map = {m.id: m for m in messages}
- search_space_ids = {m.thread.search_space_id for m in messages}
+ workspace_ids = {m.thread.workspace_id for m in messages}
permissions_cache: dict[int, set] = {}
- for ss_id in search_space_ids:
+ for ss_id in workspace_ids:
await check_permission(
session,
auth,
ss_id,
Permission.COMMENTS_READ.value,
- "You don't have permission to read comments in this search space",
+ "You don't have permission to read comments in this workspace",
)
permissions_cache[ss_id] = await get_user_permissions(session, user.id, ss_id)
@@ -338,7 +338,7 @@ async def get_comments_for_messages_batch(
comments_by_message[mid] = CommentListResponse(comments=[], total_count=0)
continue
- ss_id = msg.thread.search_space_id
+ ss_id = msg.thread.workspace_id
user_perms = permissions_cache.get(ss_id, set())
can_delete_any = has_permission(user_perms, Permission.COMMENTS_DELETE.value)
@@ -447,14 +447,14 @@ async def create_comment(
detail="Comments can only be added to AI responses",
)
- search_space_id = message.thread.search_space_id
+ workspace_id = message.thread.workspace_id
# Check permission to create comments
- user_permissions = await get_user_permissions(session, user.id, search_space_id)
+ user_permissions = await get_user_permissions(session, user.id, workspace_id)
if not has_permission(user_permissions, Permission.COMMENTS_CREATE.value):
raise HTTPException(
status_code=403,
- detail="You don't have permission to create comments in this search space",
+ detail="You don't have permission to create comments in this workspace",
)
thread = message.thread
@@ -468,7 +468,7 @@ async def create_comment(
await session.flush()
# Process mentions - returns map of user_id -> mention_id
- mentions_map = await process_mentions(session, comment.id, content, search_space_id)
+ mentions_map = await process_mentions(session, comment.id, content, workspace_id)
await session.commit()
await session.refresh(comment)
@@ -495,7 +495,7 @@ async def create_comment(
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200],
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
author = AuthorResponse(
@@ -561,14 +561,14 @@ async def create_reply(
detail="Cannot reply to a reply",
)
- search_space_id = parent_comment.message.thread.search_space_id
+ workspace_id = parent_comment.message.thread.workspace_id
# Check permission to create comments
- user_permissions = await get_user_permissions(session, user.id, search_space_id)
+ user_permissions = await get_user_permissions(session, user.id, workspace_id)
if not has_permission(user_permissions, Permission.COMMENTS_CREATE.value):
raise HTTPException(
status_code=403,
- detail="You don't have permission to create comments in this search space",
+ detail="You don't have permission to create comments in this workspace",
)
thread = parent_comment.message.thread
@@ -583,7 +583,7 @@ async def create_reply(
await session.flush()
# Process mentions - returns map of user_id -> mention_id
- mentions_map = await process_mentions(session, reply.id, content, search_space_id)
+ mentions_map = await process_mentions(session, reply.id, content, workspace_id)
await session.commit()
await session.refresh(reply)
@@ -610,7 +610,7 @@ async def create_reply(
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200],
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
# Notify thread participants (excluding replier and mentioned users)
@@ -635,7 +635,7 @@ async def create_reply(
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200],
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
author = AuthorResponse(
@@ -699,7 +699,7 @@ async def update_comment(
detail="You can only edit your own comments",
)
- search_space_id = comment.message.thread.search_space_id
+ workspace_id = comment.message.thread.workspace_id
# Get existing mentioned user IDs
existing_result = await session.execute(
@@ -712,12 +712,12 @@ async def update_comment(
# Parse new mentions from updated content
new_mention_uuids = set(parse_mentions(content))
- # Validate new mentions are search space members
+ # Validate new mentions are workspace members
if new_mention_uuids:
valid_result = await session.execute(
- select(SearchSpaceMembership.user_id).filter(
- SearchSpaceMembership.search_space_id == search_space_id,
- SearchSpaceMembership.user_id.in_(new_mention_uuids),
+ select(WorkspaceMembership.user_id).filter(
+ WorkspaceMembership.workspace_id == workspace_id,
+ WorkspaceMembership.user_id.in_(new_mention_uuids),
)
)
valid_new_mentions = set(valid_result.scalars().all())
@@ -777,7 +777,7 @@ async def update_comment(
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200],
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
author = AuthorResponse(
@@ -833,8 +833,8 @@ async def delete_comment(
is_author = comment.author_id == user.id
# Check if user has COMMENTS_DELETE permission
- search_space_id = comment.message.thread.search_space_id
- user_permissions = await get_user_permissions(session, user.id, search_space_id)
+ workspace_id = comment.message.thread.workspace_id
+ user_permissions = await get_user_permissions(session, user.id, workspace_id)
can_delete_any = has_permission(user_permissions, Permission.COMMENTS_DELETE.value)
if not is_author and not can_delete_any:
@@ -852,21 +852,21 @@ async def delete_comment(
async def get_user_mentions(
session: AsyncSession,
auth: AuthContext,
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
) -> MentionListResponse:
user = auth.user
"""
- Get mentions for the current user, optionally filtered by search space.
+ Get mentions for the current user, optionally filtered by workspace.
Args:
session: Database session
user: The current authenticated user
- search_space_id: Optional search space ID to filter mentions
+ workspace_id: Optional workspace ID to filter mentions
Returns:
MentionListResponse with mentions and total count
"""
- # Build query with joins for filtering by search_space_id
+ # Build query with joins for filtering by workspace_id
query = (
select(ChatCommentMention)
.join(ChatComment, ChatCommentMention.comment_id == ChatComment.id)
@@ -880,18 +880,18 @@ async def get_user_mentions(
.order_by(ChatCommentMention.created_at.desc())
)
- if search_space_id is not None:
- query = query.filter(NewChatThread.search_space_id == search_space_id)
+ if workspace_id is not None:
+ query = query.filter(NewChatThread.workspace_id == workspace_id)
result = await session.execute(query)
mention_records = result.scalars().all()
- # Fetch search space info for context (single query for all unique search spaces)
+ # Fetch workspace info for context (single query for all unique workspaces)
thread_ids = {m.comment.message.thread_id for m in mention_records}
if thread_ids:
thread_result = await session.execute(
select(NewChatThread)
- .options(selectinload(NewChatThread.search_space))
+ .options(selectinload(NewChatThread.workspace))
.filter(NewChatThread.id.in_(thread_ids))
)
threads_map = {t.id: t for t in thread_result.scalars().all()}
@@ -903,7 +903,7 @@ async def get_user_mentions(
comment = mention.comment
message = comment.message
thread = threads_map.get(message.thread_id)
- search_space = thread.search_space if thread else None
+ workspace = thread.workspace if thread else None
author = None
if comment.author:
@@ -934,8 +934,8 @@ async def get_user_mentions(
thread_id=thread.id if thread else 0,
thread_title=thread.title or "Untitled" if thread else "Unknown",
message_id=message.id,
- search_space_id=search_space.id if search_space else 0,
- search_space_name=search_space.name if search_space else "Unknown",
+ workspace_id=workspace.id if workspace else 0,
+ workspace_name=workspace.name if workspace else "Unknown",
),
)
)
diff --git a/surfsense_backend/app/services/confluence/kb_sync_service.py b/surfsense_backend/app/services/confluence/kb_sync_service.py
index 7154637b4..478ac8739 100644
--- a/surfsense_backend/app/services/confluence/kb_sync_service.py
+++ b/surfsense_backend/app/services/confluence/kb_sync_service.py
@@ -28,7 +28,7 @@ class ConfluenceKBSyncService:
space_id: str,
body_content: str | None,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -40,7 +40,7 @@ class ConfluenceKBSyncService:
try:
unique_hash = generate_unique_identifier_hash(
- DocumentType.CONFLUENCE_CONNECTOR, page_id, search_space_id
+ DocumentType.CONFLUENCE_CONNECTOR, page_id, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -55,7 +55,7 @@ class ConfluenceKBSyncService:
page_content = f"# {page_title}\n\n{indexable_content}"
- content_hash = generate_content_hash(page_content, search_space_id)
+ content_hash = generate_content_hash(page_content, workspace_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
@@ -85,7 +85,7 @@ class ConfluenceKBSyncService:
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@@ -126,7 +126,7 @@ class ConfluenceKBSyncService:
document_id: int,
page_id: str,
user_id: str,
- search_space_id: int,
+ workspace_id: int,
) -> dict:
from app.tasks.connector_indexers.base import (
get_current_timestamp,
@@ -170,7 +170,7 @@ class ConfluenceKBSyncService:
document.title = page_title
document.content = summary_content
- document.content_hash = generate_content_hash(page_content, search_space_id)
+ document.content_hash = generate_content_hash(page_content, workspace_id)
document.embedding = summary_embedding
from sqlalchemy.orm.attributes import flag_modified
diff --git a/surfsense_backend/app/services/confluence/tool_metadata_service.py b/surfsense_backend/app/services/confluence/tool_metadata_service.py
index a66725bc6..ac2447a1a 100644
--- a/surfsense_backend/app/services/confluence/tool_metadata_service.py
+++ b/surfsense_backend/app/services/confluence/tool_metadata_service.py
@@ -110,12 +110,12 @@ class ConfluenceToolMetadataService:
)
return True
- async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
+ async def get_creation_context(self, workspace_id: int, user_id: str) -> dict:
"""Return context needed to create a new Confluence page.
Fetches all connected accounts, and for the first healthy one fetches spaces.
"""
- connectors = await self._get_all_confluence_connectors(search_space_id, user_id)
+ connectors = await self._get_all_confluence_connectors(workspace_id, user_id)
if not connectors:
return {"error": "No Confluence account connected"}
@@ -158,13 +158,13 @@ class ConfluenceToolMetadataService:
}
async def get_update_context(
- self, search_space_id: int, user_id: str, page_ref: str
+ self, workspace_id: int, user_id: str, page_ref: str
) -> dict:
"""Return context needed to update an indexed Confluence page.
Resolves the page from KB, then fetches current content and version from API.
"""
- document = await self._resolve_page(search_space_id, user_id, page_ref)
+ document = await self._resolve_page(workspace_id, user_id, page_ref)
if not document:
return {
"error": f"Page '{page_ref}' not found in your synced Confluence pages. "
@@ -232,10 +232,10 @@ class ConfluenceToolMetadataService:
}
async def get_deletion_context(
- self, search_space_id: int, user_id: str, page_ref: str
+ self, workspace_id: int, user_id: str, page_ref: str
) -> dict:
"""Return context needed to delete a Confluence page (KB metadata only)."""
- document = await self._resolve_page(search_space_id, user_id, page_ref)
+ document = await self._resolve_page(workspace_id, user_id, page_ref)
if not document:
return {
"error": f"Page '{page_ref}' not found in your synced Confluence pages. "
@@ -256,7 +256,7 @@ class ConfluenceToolMetadataService:
}
async def _resolve_page(
- self, search_space_id: int, user_id: str, page_ref: str
+ self, workspace_id: int, user_id: str, page_ref: str
) -> Document | None:
"""Resolve a page from KB: page_title -> document.title."""
ref_lower = page_ref.lower()
@@ -268,7 +268,7 @@ class ConfluenceToolMetadataService:
)
.filter(
and_(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.CONFLUENCE_CONNECTOR,
SearchSourceConnector.user_id == user_id,
or_(
@@ -284,12 +284,12 @@ class ConfluenceToolMetadataService:
return result.scalars().first()
async def _get_all_confluence_connectors(
- self, search_space_id: int, user_id: str
+ self, workspace_id: int, user_id: str
) -> list[SearchSourceConnector]:
result = await self._db_session.execute(
select(SearchSourceConnector).filter(
and_(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
diff --git a/surfsense_backend/app/services/connector_service.py b/surfsense_backend/app/services/connector_service.py
index 2694a8e69..7c3f0543d 100644
--- a/surfsense_backend/app/services/connector_service.py
+++ b/surfsense_backend/app/services/connector_service.py
@@ -4,12 +4,9 @@ from datetime import datetime
from threading import Lock
from typing import Any
-import httpx
-from linkup import LinkupClient
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
-from tavily import TavilyClient
from app.config import config
from app.db import (
@@ -26,11 +23,11 @@ from app.utils.perf import get_perf_logger
class ConnectorService:
- def __init__(self, session: AsyncSession, search_space_id: int | None = None):
+ def __init__(self, session: AsyncSession, workspace_id: int | None = None):
self.session = session
self.chunk_retriever = ChucksHybridSearchRetriever(session)
self.document_retriever = DocumentHybridSearchRetriever(session)
- self.search_space_id = search_space_id
+ self.workspace_id = workspace_id
self.source_id_counter = (
100000 # High starting value to avoid collisions with existing IDs
)
@@ -40,122 +37,32 @@ class ConnectorService:
async def initialize_counter(self):
"""
- Initialize the source_id_counter based on the total number of chunks for the search space.
+ Initialize the source_id_counter based on the total number of chunks for the workspace.
This ensures unique IDs across different sessions.
"""
- if self.search_space_id:
+ if self.workspace_id:
try:
- # Count total chunks for documents belonging to this search space
+ # Count total chunks for documents belonging to this workspace
result = await self.session.execute(
select(func.count(Chunk.id))
.join(Document)
- .filter(Document.search_space_id == self.search_space_id)
+ .filter(Document.workspace_id == self.workspace_id)
)
chunk_count = result.scalar() or 0
self.source_id_counter = chunk_count + 1
print(
- f"Initialized source_id_counter to {self.source_id_counter} for search space {self.search_space_id}"
+ f"Initialized source_id_counter to {self.source_id_counter} for workspace {self.workspace_id}"
)
except Exception as e:
print(f"Error initializing source_id_counter: {e!s}")
# Fallback to default value
self.source_id_counter = 1
- async def search_crawled_urls(
- self,
- user_query: str,
- search_space_id: int,
- top_k: int = 20,
- start_date: datetime | None = None,
- end_date: datetime | None = None,
- ) -> tuple:
- """
- Search for crawled URLs and return both the source information and langchain documents.
-
- Uses combined chunk-level and document-level hybrid search with RRF fusion.
-
- Args:
- user_query: The user's query
- search_space_id: The search space ID to search in
- top_k: Maximum number of results to return
- start_date: Optional start date for filtering documents by updated_at
- end_date: Optional end date for filtering documents by updated_at
-
- Returns:
- tuple: (sources_info, langchain_documents)
- """
- crawled_urls_docs = await self._combined_rrf_search(
- query_text=user_query,
- search_space_id=search_space_id,
- document_type="CRAWLED_URL",
- top_k=top_k,
- start_date=start_date,
- end_date=end_date,
- )
-
- # Early return if no results
- if not crawled_urls_docs:
- return {
- "id": 1,
- "name": "Crawled URLs",
- "type": "CRAWLED_URL",
- "sources": [],
- }, []
-
- def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
- return doc_info.get("title") or metadata.get("title") or "Untitled Document"
-
- def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
- return metadata.get("source") or metadata.get("url") or ""
-
- def _description_fn(
- chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
- ) -> str:
- description = metadata.get("description") or self._chunk_preview(
- chunk.get("content", "")
- )
- info_parts = []
- language = metadata.get("language", "")
- last_crawled_at = metadata.get("last_crawled_at", "")
- if language:
- info_parts.append(f"Language: {language}")
- if last_crawled_at:
- info_parts.append(f"Last crawled: {last_crawled_at}")
- if info_parts:
- description = (description + " | " + " | ".join(info_parts)).strip(" |")
- return description
-
- def _extra_fields_fn(
- _chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
- ) -> dict[str, Any]:
- return {
- "language": metadata.get("language", ""),
- "last_crawled_at": metadata.get("last_crawled_at", ""),
- }
-
- sources_list = self._build_chunk_sources_from_documents(
- crawled_urls_docs,
- title_fn=_title_fn,
- description_fn=_description_fn,
- url_fn=_url_fn,
- extra_fields_fn=_extra_fields_fn,
- )
-
- # Create result object
- result_object = {
- "id": 1,
- "name": "Crawled URLs",
- "type": "CRAWLED_URL",
- "sources": sources_list,
- }
-
- return result_object, crawled_urls_docs
-
async def search_files(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -167,7 +74,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -177,7 +84,7 @@ class ConnectorService:
"""
files_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="FILE",
top_k=top_k,
start_date=start_date,
@@ -221,7 +128,7 @@ class ConnectorService:
async def _combined_rrf_search(
self,
query_text: str,
- search_space_id: int,
+ workspace_id: int,
document_type: str | list[str],
top_k: int = 20,
start_date: datetime | None = None,
@@ -243,7 +150,7 @@ class ConnectorService:
Args:
query_text: The search query text
- search_space_id: The search space ID to search within
+ workspace_id: The workspace ID to search within
document_type: Document type(s) to filter (e.g., "FILE", "CRAWLED_URL",
or a list for multi-type queries)
top_k: Number of results to return
@@ -289,7 +196,7 @@ class ConnectorService:
search_kwargs = {
"query_text": query_text,
"top_k": retriever_top_k,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"document_type": resolved_type,
"start_date": start_date,
"end_date": end_date,
@@ -388,7 +295,7 @@ class ConnectorService:
time.perf_counter() - t0,
len(combined_results),
document_type,
- search_space_id,
+ workspace_id,
)
return combined_results
@@ -458,387 +365,30 @@ class ConnectorService:
async def get_connector_by_type(
self,
connector_type: SearchSourceConnectorType,
- search_space_id: int,
+ workspace_id: int,
) -> SearchSourceConnector | None:
"""
- Get a connector by type for a specific search space
+ Get a connector by type for a specific workspace
Args:
connector_type: The connector type to retrieve
- search_space_id: The search space ID to filter by
+ workspace_id: The workspace ID to filter by
Returns:
Optional[SearchSourceConnector]: The connector if found, None otherwise
"""
query = select(SearchSourceConnector).filter(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.connector_type == connector_type,
)
result = await self.session.execute(query)
return result.scalars().first()
- async def search_tavily(
- self, user_query: str, search_space_id: int, top_k: int = 20
- ) -> tuple:
- """
- Search using Tavily API and return both the source information and documents
-
- Args:
- user_query: The user's query
- search_space_id: The search space ID
- top_k: Maximum number of results to return
-
- Returns:
- tuple: (sources_info, documents)
- """
- # Get Tavily connector configuration
- tavily_connector = await self.get_connector_by_type(
- SearchSourceConnectorType.TAVILY_API, search_space_id
- )
-
- if not tavily_connector:
- # Return empty results if no Tavily connector is configured
- return {
- "id": 3,
- "name": "Tavily Search",
- "type": "TAVILY_API",
- "sources": [],
- }, []
-
- # Initialize Tavily client with API key from connector config
- tavily_api_key = tavily_connector.config.get("TAVILY_API_KEY")
- tavily_client = TavilyClient(api_key=tavily_api_key)
-
- # Perform search with Tavily
- try:
- response = tavily_client.search(
- query=user_query,
- max_results=top_k,
- search_depth="advanced", # Use advanced search for better results
- )
-
- # Extract results from Tavily response
- tavily_results = response.get("results", [])
-
- # Early return if no results
- if not tavily_results:
- return {
- "id": 3,
- "name": "Tavily Search",
- "type": "TAVILY_API",
- "sources": [],
- }, []
-
- # Process each result and create sources directly without deduplication
- sources_list = []
- documents = []
-
- async with self.counter_lock:
- for _i, result in enumerate(tavily_results):
- # Create a source entry
- source = {
- "id": self.source_id_counter,
- "title": result.get("title", "Tavily Result"),
- "description": result.get("content", ""),
- "url": result.get("url", ""),
- }
- sources_list.append(source)
-
- # Create a document entry
- document = {
- "chunk_id": self.source_id_counter,
- "content": result.get("content", ""),
- "score": result.get("score", 0.0),
- "document": {
- "id": self.source_id_counter,
- "title": result.get("title", "Tavily Result"),
- "document_type": "TAVILY_API",
- "metadata": {
- "url": result.get("url", ""),
- "published_date": result.get("published_date", ""),
- "source": "TAVILY_API",
- },
- },
- }
- documents.append(document)
- self.source_id_counter += 1
-
- # Create result object
- result_object = {
- "id": 3,
- "name": "Tavily Search",
- "type": "TAVILY_API",
- "sources": sources_list,
- }
-
- return result_object, documents
-
- except Exception as e:
- # Log the error and return empty results
- print(f"Error searching with Tavily: {e!s}")
- return {
- "id": 3,
- "name": "Tavily Search",
- "type": "TAVILY_API",
- "sources": [],
- }, []
-
- async def search_searxng(
- self,
- user_query: str,
- search_space_id: int,
- top_k: int = 20,
- ) -> tuple:
- """Search using the platform SearXNG instance.
-
- Delegates to ``WebSearchService`` which handles caching, circuit
- breaking, and retries. SearXNG configuration comes from the
- docker/searxng/settings.yml file.
- """
- from app.services import web_search_service
-
- if not web_search_service.is_available():
- return {
- "id": 11,
- "name": "Web Search",
- "type": "SEARXNG_API",
- "sources": [],
- }, []
-
- return await web_search_service.search(
- query=user_query,
- top_k=top_k,
- )
-
- async def search_baidu(
- self,
- user_query: str,
- search_space_id: int,
- top_k: int = 20,
- ) -> tuple:
- """
- Search using Baidu AI Search API and return both sources and documents.
-
- Baidu AI Search provides intelligent search with automatic summarization.
- We extract the raw search results (references) from the API response.
-
- Args:
- user_query: User's search query
- search_space_id: Search space ID
- top_k: Maximum number of results to return
-
- Returns:
- tuple: (sources_info_dict, documents_list)
- """
- # Get Baidu connector configuration
- baidu_connector = await self.get_connector_by_type(
- SearchSourceConnectorType.BAIDU_SEARCH_API, search_space_id
- )
-
- if not baidu_connector:
- return {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": [],
- }, []
-
- config = baidu_connector.config or {}
- api_key = config.get("BAIDU_API_KEY")
-
- if not api_key:
- print("ERROR: Baidu connector is missing BAIDU_API_KEY configuration")
- print(f"Connector config: {config}")
- return {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": [],
- }, []
-
- # Optional configuration parameters
- model = config.get("BAIDU_MODEL", "ernie-3.5-8k")
- search_source = config.get("BAIDU_SEARCH_SOURCE", "baidu_search_v2")
- enable_deep_search = config.get("BAIDU_ENABLE_DEEP_SEARCH", False)
-
- # Baidu AI Search API endpoint
- baidu_endpoint = "https://qianfan.baidubce.com/v2/ai_search/chat/completions"
-
- # Prepare request headers
- # Note: Baidu uses X-Appbuilder-Authorization instead of standard Authorization header
- headers = {
- "X-Appbuilder-Authorization": f"Bearer {api_key}",
- "Content-Type": "application/json",
- }
-
- # Prepare request payload
- # Calculate resource_type_filter top_k values
- # Baidu v2 supports max 20 per type
- max_per_type = min(top_k, 20)
-
- payload = {
- "messages": [{"role": "user", "content": user_query}],
- "model": model,
- "search_source": search_source,
- "resource_type_filter": [
- {"type": "web", "top_k": max_per_type},
- {"type": "video", "top_k": max(1, max_per_type // 4)}, # Fewer videos
- ],
- "stream": False, # Non-streaming for simpler processing
- "enable_deep_search": enable_deep_search,
- "enable_corner_markers": True, # Enable reference markers
- }
-
- try:
- # Baidu AI Search may take longer as it performs search + summarization
- # Increase timeout to 90 seconds
- async with httpx.AsyncClient(timeout=90.0) as client:
- response = await client.post(
- baidu_endpoint,
- headers=headers,
- json=payload,
- )
- response.raise_for_status()
- except httpx.TimeoutException as exc:
- print(f"ERROR: Baidu API request timeout after 90s: {exc!r}")
- print(f"Endpoint: {baidu_endpoint}")
- return {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": [],
- }, []
- except httpx.HTTPStatusError as exc:
- print(f"ERROR: Baidu API HTTP Status Error: {exc.response.status_code}")
- print(f"Response text: {exc.response.text[:500]}")
- print(f"Request URL: {exc.request.url}")
- return {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": [],
- }, []
- except httpx.RequestError as exc:
- print(f"ERROR: Baidu API Request Error: {type(exc).__name__}: {exc!r}")
- print(f"Endpoint: {baidu_endpoint}")
- return {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": [],
- }, []
- except Exception as exc:
- print(
- f"ERROR: Unexpected error calling Baidu API: {type(exc).__name__}: {exc!r}"
- )
- print(f"Endpoint: {baidu_endpoint}")
- print(f"Payload: {payload}")
- return {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": [],
- }, []
-
- try:
- data = response.json()
- except ValueError as e:
- print(f"ERROR: Failed to decode JSON response from Baidu AI Search: {e}")
- print(f"Response status: {response.status_code}")
- print(f"Response text: {response.text[:500]}") # First 500 chars
- return {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": [],
- }, []
-
- # Extract references (search results) from the response
- baidu_references = data.get("references", [])
-
- if "code" in data or "message" in data:
- print(
- f"WARNING: Baidu API returned error - Code: {data.get('code')}, Message: {data.get('message')}"
- )
-
- if not baidu_references:
- print("WARNING: No references found in Baidu API response")
- print(f"Response keys: {list(data.keys())}")
- return {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": [],
- }, []
-
- sources_list: list[dict[str, Any]] = []
- documents: list[dict[str, Any]] = []
-
- async with self.counter_lock:
- for reference in baidu_references:
- # Extract basic fields
- title = reference.get("title", "Baidu Search Result")
- url = reference.get("url", "")
- content = reference.get("content", "")
- date = reference.get("date", "")
- ref_type = reference.get("type", "web") # web, image, video
-
- # Create a source entry
- source = {
- "id": self.source_id_counter,
- "title": title,
- "description": content[:300]
- if content
- else "", # Limit description length
- "url": url,
- }
- sources_list.append(source)
-
- # Prepare metadata
- metadata = {
- "url": url,
- "date": date,
- "type": ref_type,
- "source": "BAIDU_SEARCH_API",
- "web_anchor": reference.get("web_anchor", ""),
- "website": reference.get("website", ""),
- }
-
- # Add type-specific metadata
- if ref_type == "image" and reference.get("image"):
- metadata["image"] = reference["image"]
- elif ref_type == "video" and reference.get("video"):
- metadata["video"] = reference["video"]
-
- # Create a document entry
- document = {
- "chunk_id": self.source_id_counter,
- "content": content,
- "score": 1.0, # Baidu doesn't provide relevance scores
- "document": {
- "id": self.source_id_counter,
- "title": title,
- "document_type": "BAIDU_SEARCH_API",
- "metadata": metadata,
- },
- }
- documents.append(document)
- self.source_id_counter += 1
-
- result_object = {
- "id": 12,
- "name": "Baidu Search",
- "type": "BAIDU_SEARCH_API",
- "sources": sources_list,
- }
-
- return result_object, documents
-
async def search_slack(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -850,7 +400,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -860,7 +410,7 @@ class ConnectorService:
"""
slack_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="SLACK_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -912,7 +462,7 @@ class ConnectorService:
async def search_notion(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -924,7 +474,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -934,7 +484,7 @@ class ConnectorService:
"""
notion_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="NOTION_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -982,7 +532,7 @@ class ConnectorService:
async def search_extension(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -994,7 +544,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1004,7 +554,7 @@ class ConnectorService:
"""
extension_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="EXTENSION",
top_k=top_k,
start_date=start_date,
@@ -1079,7 +629,7 @@ class ConnectorService:
async def search_youtube(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1091,7 +641,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1101,7 +651,7 @@ class ConnectorService:
"""
youtube_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="YOUTUBE_VIDEO",
top_k=top_k,
start_date=start_date,
@@ -1160,7 +710,7 @@ class ConnectorService:
async def search_github(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1172,7 +722,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1182,7 +732,7 @@ class ConnectorService:
"""
github_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="GITHUB_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -1219,7 +769,7 @@ class ConnectorService:
async def search_linear(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1231,7 +781,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1241,7 +791,7 @@ class ConnectorService:
"""
linear_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="LINEAR_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -1319,7 +869,7 @@ class ConnectorService:
async def search_jira(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1331,7 +881,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1341,7 +891,7 @@ class ConnectorService:
"""
jira_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="JIRA_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -1421,7 +971,7 @@ class ConnectorService:
async def search_google_calendar(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1433,7 +983,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1443,7 +993,7 @@ class ConnectorService:
"""
calendar_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="GOOGLE_CALENDAR_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -1527,7 +1077,7 @@ class ConnectorService:
async def search_airtable(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1539,7 +1089,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1549,7 +1099,7 @@ class ConnectorService:
"""
airtable_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="AIRTABLE_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -1603,7 +1153,7 @@ class ConnectorService:
async def search_google_gmail(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1615,7 +1165,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1625,7 +1175,7 @@ class ConnectorService:
"""
gmail_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="GOOGLE_GMAIL_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -1703,7 +1253,7 @@ class ConnectorService:
async def search_google_drive(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1715,7 +1265,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1725,7 +1275,7 @@ class ConnectorService:
"""
drive_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="GOOGLE_DRIVE_FILE",
top_k=top_k,
start_date=start_date,
@@ -1803,7 +1353,7 @@ class ConnectorService:
async def search_confluence(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1815,7 +1365,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1825,7 +1375,7 @@ class ConnectorService:
"""
confluence_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="CONFLUENCE_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -1874,7 +1424,7 @@ class ConnectorService:
async def search_clickup(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -1886,7 +1436,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -1896,7 +1446,7 @@ class ConnectorService:
"""
clickup_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="CLICKUP_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -1965,131 +1515,10 @@ class ConnectorService:
return result_object, clickup_docs
- async def search_linkup(
- self,
- user_query: str,
- search_space_id: int,
- mode: str = "standard",
- ) -> tuple:
- """
- Search using Linkup API and return both the source information and documents
-
- Args:
- user_query: The user's query
- search_space_id: The search space ID
- mode: Search depth mode, can be "standard" or "deep"
-
- Returns:
- tuple: (sources_info, documents)
- """
- # Get Linkup connector configuration
- linkup_connector = await self.get_connector_by_type(
- SearchSourceConnectorType.LINKUP_API, search_space_id
- )
-
- if not linkup_connector:
- # Return empty results if no Linkup connector is configured
- return {
- "id": 10,
- "name": "Linkup Search",
- "type": "LINKUP_API",
- "sources": [],
- }, []
-
- # Initialize Linkup client with API key from connector config
- linkup_api_key = linkup_connector.config.get("LINKUP_API_KEY")
- linkup_client = LinkupClient(api_key=linkup_api_key)
-
- # Perform search with Linkup
- try:
- response = linkup_client.search(
- query=user_query,
- depth=mode, # Use the provided mode ("standard" or "deep")
- output_type="searchResults", # Default to search results
- )
-
- # Extract results from Linkup response - access as attribute instead of using .get()
- linkup_results = response.results if hasattr(response, "results") else []
-
- # Only proceed if we have results
- if not linkup_results:
- return {
- "id": 10,
- "name": "Linkup Search",
- "type": "LINKUP_API",
- "sources": [],
- }, []
-
- # Process each result and create sources directly without deduplication
- sources_list = []
- documents = []
-
- async with self.counter_lock:
- for _i, result in enumerate(linkup_results):
- # Only process results that have content
- if not hasattr(result, "content") or not result.content:
- continue
-
- # Create a source entry
- source = {
- "id": self.source_id_counter,
- "title": (
- result.name if hasattr(result, "name") else "Linkup Result"
- ),
- "description": (
- result.content if hasattr(result, "content") else ""
- ),
- "url": result.url if hasattr(result, "url") else "",
- }
- sources_list.append(source)
-
- # Create a document entry
- document = {
- "chunk_id": self.source_id_counter,
- "content": result.content if hasattr(result, "content") else "",
- "score": 1.0, # Default score since not provided by Linkup
- "document": {
- "id": self.source_id_counter,
- "title": (
- result.name
- if hasattr(result, "name")
- else "Linkup Result"
- ),
- "document_type": "LINKUP_API",
- "metadata": {
- "url": result.url if hasattr(result, "url") else "",
- "type": result.type if hasattr(result, "type") else "",
- "source": "LINKUP_API",
- },
- },
- }
- documents.append(document)
- self.source_id_counter += 1
-
- # Create result object
- result_object = {
- "id": 10,
- "name": "Linkup Search",
- "type": "LINKUP_API",
- "sources": sources_list,
- }
-
- return result_object, documents
-
- except Exception as e:
- # Log the error and return empty results
- print(f"Error searching with Linkup: {e!s}")
- return {
- "id": 10,
- "name": "Linkup Search",
- "type": "LINKUP_API",
- "sources": [],
- }, []
-
async def search_discord(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -2101,7 +1530,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -2111,7 +1540,7 @@ class ConnectorService:
"""
discord_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="DISCORD_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -2164,7 +1593,7 @@ class ConnectorService:
async def search_teams(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -2176,7 +1605,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -2186,7 +1615,7 @@ class ConnectorService:
"""
teams_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="TEAMS_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -2238,7 +1667,7 @@ class ConnectorService:
async def search_luma(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -2250,7 +1679,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -2260,7 +1689,7 @@ class ConnectorService:
"""
luma_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="LUMA_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -2343,7 +1772,7 @@ class ConnectorService:
async def search_elasticsearch(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -2355,7 +1784,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -2365,7 +1794,7 @@ class ConnectorService:
"""
elasticsearch_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="ELASTICSEARCH_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -2429,7 +1858,7 @@ class ConnectorService:
async def search_notes(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -2441,7 +1870,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -2451,7 +1880,7 @@ class ConnectorService:
"""
notes_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="NOTE",
top_k=top_k,
start_date=start_date,
@@ -2498,7 +1927,7 @@ class ConnectorService:
async def search_bookstack(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -2511,7 +1940,7 @@ class ConnectorService:
Args:
user_query: The user's query
user_id: The user's ID
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -2521,7 +1950,7 @@ class ConnectorService:
"""
bookstack_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="BOOKSTACK_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -2572,7 +2001,7 @@ class ConnectorService:
async def search_circleback(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -2584,7 +2013,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -2594,7 +2023,7 @@ class ConnectorService:
"""
circleback_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="CIRCLEBACK",
top_k=top_k,
start_date=start_date,
@@ -2672,7 +2101,7 @@ class ConnectorService:
async def search_obsidian(
self,
user_query: str,
- search_space_id: int,
+ workspace_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
@@ -2684,7 +2113,7 @@ class ConnectorService:
Args:
user_query: The user's query
- search_space_id: The search space ID to search in
+ workspace_id: The workspace ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
@@ -2694,7 +2123,7 @@ class ConnectorService:
"""
obsidian_docs = await self._combined_rrf_search(
query_text=user_query,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
document_type="OBSIDIAN_CONNECTOR",
top_k=top_k,
start_date=start_date,
@@ -2766,60 +2195,60 @@ class ConnectorService:
async def get_available_connectors(
self,
- search_space_id: int,
+ workspace_id: int,
) -> list[SearchSourceConnectorType]:
"""
- Get all available (enabled) connector types for a search space.
+ Get all available (enabled) connector types for a workspace.
- Phase 1.4: results are cached per ``search_space_id`` for
+ Phase 1.4: results are cached per ``workspace_id`` for
:data:`_DISCOVERY_TTL_SECONDS`. Cache key is independent of session
identity — the cached value is plain data, safe to share across
requests. Invalidate on connector add/update/delete via
:func:`invalidate_connector_discovery_cache`.
Args:
- search_space_id: The search space ID
+ workspace_id: The workspace ID
Returns:
List of SearchSourceConnectorType enums for enabled connectors
"""
- cached = _get_cached_connectors(search_space_id)
+ cached = _get_cached_connectors(workspace_id)
if cached is not None:
return list(cached)
query = (
select(SearchSourceConnector.connector_type)
.filter(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
)
.distinct()
)
result = await self.session.execute(query)
connector_types = list(result.scalars().all())
- _set_cached_connectors(search_space_id, connector_types)
+ _set_cached_connectors(workspace_id, connector_types)
return connector_types
async def get_available_document_types(
self,
- search_space_id: int,
+ workspace_id: int,
) -> list[str]:
"""
- Get all document types that have at least one document in the search space.
+ Get all document types that have at least one document in the workspace.
- Phase 1.4: cached per ``search_space_id`` for
+ Phase 1.4: cached per ``workspace_id`` for
:data:`_DISCOVERY_TTL_SECONDS`. Invalidate via
:func:`invalidate_connector_discovery_cache` when a connector
finishes indexing new documents (or document types are otherwise
added/removed).
Args:
- search_space_id: The search space ID
+ workspace_id: The workspace ID
Returns:
List of document type strings that have documents indexed
"""
- cached = _get_cached_doc_types(search_space_id)
+ cached = _get_cached_doc_types(workspace_id)
if cached is not None:
return list(cached)
@@ -2828,12 +2257,12 @@ class ConnectorService:
from app.db import Document
query = select(distinct(Document.document_type)).filter(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
)
result = await self.session.execute(query)
doc_types = [str(dt) for dt in result.scalars().all()]
- _set_cached_doc_types(search_space_id, doc_types)
+ _set_cached_doc_types(workspace_id, doc_types)
return doc_types
@@ -2850,7 +2279,7 @@ class ConnectorService:
# DB roundtrip with bounded staleness.
#
# Invalidation: connector mutation routes (create / update / delete) call
-# ``invalidate_connector_discovery_cache(search_space_id)`` to clear the
+# ``invalidate_connector_discovery_cache(workspace_id)`` to clear the
# entry for the affected space. Multi-replica deployments still pay one
# DB roundtrip per replica per TTL window, which is fine — staleness is
# bounded and the alternative (cross-replica fanout) is not worth the
@@ -2858,7 +2287,7 @@ class ConnectorService:
_DISCOVERY_TTL_SECONDS: float = config.CONNECTOR_DISCOVERY_TTL_SECONDS
-# Per-search-space caches. Keyed by ``search_space_id``; value is
+# Per-workspace caches. Keyed by ``workspace_id``; value is
# ``(expires_at_monotonic, payload)``. Plain dicts protected by a lock —
# read-mostly workload, sub-microsecond contention.
_connectors_cache: dict[int, tuple[float, list[SearchSourceConnectorType]]] = {}
@@ -2867,55 +2296,55 @@ _cache_lock = Lock()
def _get_cached_connectors(
- search_space_id: int,
+ workspace_id: int,
) -> list[SearchSourceConnectorType] | None:
if _DISCOVERY_TTL_SECONDS <= 0:
return None
with _cache_lock:
- entry = _connectors_cache.get(search_space_id)
+ entry = _connectors_cache.get(workspace_id)
if entry is None:
return None
expires_at, payload = entry
if time.monotonic() >= expires_at:
- _connectors_cache.pop(search_space_id, None)
+ _connectors_cache.pop(workspace_id, None)
return None
return payload
def _set_cached_connectors(
- search_space_id: int, payload: list[SearchSourceConnectorType]
+ workspace_id: int, payload: list[SearchSourceConnectorType]
) -> None:
if _DISCOVERY_TTL_SECONDS <= 0:
return
expires_at = time.monotonic() + _DISCOVERY_TTL_SECONDS
with _cache_lock:
- _connectors_cache[search_space_id] = (expires_at, list(payload))
+ _connectors_cache[workspace_id] = (expires_at, list(payload))
-def _get_cached_doc_types(search_space_id: int) -> list[str] | None:
+def _get_cached_doc_types(workspace_id: int) -> list[str] | None:
if _DISCOVERY_TTL_SECONDS <= 0:
return None
with _cache_lock:
- entry = _doc_types_cache.get(search_space_id)
+ entry = _doc_types_cache.get(workspace_id)
if entry is None:
return None
expires_at, payload = entry
if time.monotonic() >= expires_at:
- _doc_types_cache.pop(search_space_id, None)
+ _doc_types_cache.pop(workspace_id, None)
return None
return payload
-def _set_cached_doc_types(search_space_id: int, payload: list[str]) -> None:
+def _set_cached_doc_types(workspace_id: int, payload: list[str]) -> None:
if _DISCOVERY_TTL_SECONDS <= 0:
return
expires_at = time.monotonic() + _DISCOVERY_TTL_SECONDS
with _cache_lock:
- _doc_types_cache[search_space_id] = (expires_at, list(payload))
+ _doc_types_cache[workspace_id] = (expires_at, list(payload))
-def invalidate_connector_discovery_cache(search_space_id: int | None = None) -> None:
- """Drop cached discovery results for ``search_space_id`` (or all spaces).
+def invalidate_connector_discovery_cache(workspace_id: int | None = None) -> None:
+ """Drop cached discovery results for ``workspace_id`` (or all spaces).
Connector CRUD routes / indexer pipelines call this when they mutate
the rows backing :func:`ConnectorService.get_available_connectors` /
@@ -2923,28 +2352,28 @@ def invalidate_connector_discovery_cache(search_space_id: int | None = None) ->
useful in tests and on bulk imports.
"""
with _cache_lock:
- if search_space_id is None:
+ if workspace_id is None:
_connectors_cache.clear()
_doc_types_cache.clear()
else:
- _connectors_cache.pop(search_space_id, None)
- _doc_types_cache.pop(search_space_id, None)
+ _connectors_cache.pop(workspace_id, None)
+ _doc_types_cache.pop(workspace_id, None)
-def _invalidate_connectors_only(search_space_id: int | None = None) -> None:
+def _invalidate_connectors_only(workspace_id: int | None = None) -> None:
with _cache_lock:
- if search_space_id is None:
+ if workspace_id is None:
_connectors_cache.clear()
else:
- _connectors_cache.pop(search_space_id, None)
+ _connectors_cache.pop(workspace_id, None)
-def _invalidate_doc_types_only(search_space_id: int | None = None) -> None:
+def _invalidate_doc_types_only(workspace_id: int | None = None) -> None:
with _cache_lock:
- if search_space_id is None:
+ if workspace_id is None:
_doc_types_cache.clear()
else:
- _doc_types_cache.pop(search_space_id, None)
+ _doc_types_cache.pop(workspace_id, None)
def _register_invalidation_listeners() -> None:
@@ -2952,7 +2381,7 @@ def _register_invalidation_listeners() -> None:
Listening on ``after_insert`` / ``after_update`` / ``after_delete``
means every successful INSERT/UPDATE/DELETE that goes through the ORM
- invalidates the affected search space's cached discovery payload —
+ invalidates the affected workspace's cached discovery payload —
no need to sprinkle ``invalidate_*`` calls across 30+ connector
routes. Bulk operations that bypass the ORM (e.g.
``session.execute(insert(...))`` without a mapped object) still need
@@ -2967,12 +2396,12 @@ def _register_invalidation_listeners() -> None:
from app.db import Document, SearchSourceConnector
def _connector_changed(_mapper, _connection, target) -> None:
- sid = getattr(target, "search_space_id", None)
+ sid = getattr(target, "workspace_id", None)
if sid is not None:
_invalidate_connectors_only(int(sid))
def _document_changed(_mapper, _connection, target) -> None:
- sid = getattr(target, "search_space_id", None)
+ sid = getattr(target, "workspace_id", None)
if sid is not None:
_invalidate_doc_types_only(int(sid))
diff --git a/surfsense_backend/app/services/dropbox/kb_sync_service.py b/surfsense_backend/app/services/dropbox/kb_sync_service.py
index a25cc054d..92ad68a79 100644
--- a/surfsense_backend/app/services/dropbox/kb_sync_service.py
+++ b/surfsense_backend/app/services/dropbox/kb_sync_service.py
@@ -26,7 +26,7 @@ class DropboxKBSyncService:
web_url: str | None,
content: str | None,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -38,7 +38,7 @@ class DropboxKBSyncService:
try:
unique_hash = compute_identifier_hash(
- DocumentType.DROPBOX_FILE.value, file_id, search_space_id
+ DocumentType.DROPBOX_FILE.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -56,7 +56,7 @@ class DropboxKBSyncService:
if not indexable_content:
indexable_content = f"Dropbox file: {file_name}"
- content_hash = generate_content_hash(indexable_content, search_space_id)
+ content_hash = generate_content_hash(indexable_content, workspace_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
@@ -93,7 +93,7 @@ class DropboxKBSyncService:
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
source_markdown=content,
updated_at=get_current_timestamp(),
diff --git a/surfsense_backend/app/services/export_service.py b/surfsense_backend/app/services/export_service.py
index 9e6869fe1..2d1a8c2cf 100644
--- a/surfsense_backend/app/services/export_service.py
+++ b/surfsense_backend/app/services/export_service.py
@@ -81,7 +81,7 @@ class ExportResult:
async def build_export_zip(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
folder_id: int | None = None,
) -> ExportResult:
"""Build a ZIP archive of markdown documents preserving folder structure.
@@ -93,13 +93,13 @@ async def build_export_zip(
"""
if folder_id is not None:
folder = await session.get(Folder, folder_id)
- if not folder or folder.search_space_id != search_space_id:
+ if not folder or folder.workspace_id != workspace_id:
raise ValueError("Folder not found")
target_folder_ids = set(await get_folder_subtree_ids(session, folder_id))
else:
target_folder_ids = None
- folder_query = select(Folder).where(Folder.search_space_id == search_space_id)
+ folder_query = select(Folder).where(Folder.workspace_id == workspace_id)
if target_folder_ids is not None:
folder_query = folder_query.where(Folder.id.in_(target_folder_ids))
folder_result = await session.execute(folder_query)
@@ -109,7 +109,7 @@ async def build_export_zip(
batch_size = 100
- base_doc_query = select(Document).where(Document.search_space_id == search_space_id)
+ base_doc_query = select(Document).where(Document.workspace_id == workspace_id)
if target_folder_ids is not None:
base_doc_query = base_doc_query.where(Document.folder_id.in_(target_folder_ids))
base_doc_query = base_doc_query.order_by(Document.id)
diff --git a/surfsense_backend/app/services/folder_service.py b/surfsense_backend/app/services/folder_service.py
index f5b608600..c34d0132c 100644
--- a/surfsense_backend/app/services/folder_service.py
+++ b/surfsense_backend/app/services/folder_service.py
@@ -111,7 +111,7 @@ async def check_no_circular_reference(
async def generate_folder_position(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
parent_id: int | None,
before_position: str | None = None,
after_position: str | None = None,
@@ -129,7 +129,7 @@ async def generate_folder_position(
query = (
select(Folder.position)
.where(
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
Folder.parent_id == parent_id
if parent_id is not None
else Folder.parent_id.is_(None),
@@ -144,7 +144,7 @@ async def generate_folder_position(
async def ensure_folder_hierarchy_with_depth_validation(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
path_segments: list[dict],
) -> Folder:
"""Create or return a nested folder chain, validating depth at each step.
@@ -163,7 +163,7 @@ async def ensure_folder_hierarchy_with_depth_validation(
metadata = segment.get("metadata")
stmt = select(Folder).where(
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
Folder.name == name,
Folder.parent_id == parent_id
if parent_id is not None
@@ -174,12 +174,10 @@ async def ensure_folder_hierarchy_with_depth_validation(
if folder is None:
await validate_folder_depth(session, parent_id, subtree_depth=0)
- position = await generate_folder_position(
- session, search_space_id, parent_id
- )
+ position = await generate_folder_position(session, workspace_id, parent_id)
folder = Folder(
name=name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
parent_id=parent_id,
position=position,
folder_metadata=metadata,
diff --git a/surfsense_backend/app/services/gmail/kb_sync_service.py b/surfsense_backend/app/services/gmail/kb_sync_service.py
index 192570339..5cb640ddb 100644
--- a/surfsense_backend/app/services/gmail/kb_sync_service.py
+++ b/surfsense_backend/app/services/gmail/kb_sync_service.py
@@ -28,7 +28,7 @@ class GmailKBSyncService:
date_str: str,
body_text: str | None,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
draft_id: str | None = None,
) -> dict:
@@ -41,7 +41,7 @@ class GmailKBSyncService:
try:
unique_hash = generate_unique_identifier_hash(
- DocumentType.GOOGLE_GMAIL_CONNECTOR, message_id, search_space_id
+ DocumentType.GOOGLE_GMAIL_CONNECTOR, message_id, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -62,7 +62,7 @@ class GmailKBSyncService:
if not indexable_content:
indexable_content = f"Gmail message: {subject}"
- content_hash = generate_content_hash(indexable_content, search_space_id)
+ content_hash = generate_content_hash(indexable_content, workspace_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
@@ -103,7 +103,7 @@ class GmailKBSyncService:
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
source_markdown=body_text,
updated_at=get_current_timestamp(),
diff --git a/surfsense_backend/app/services/gmail/tool_metadata_service.py b/surfsense_backend/app/services/gmail/tool_metadata_service.py
index 4855c1cc9..40d80b41b 100644
--- a/surfsense_backend/app/services/gmail/tool_metadata_service.py
+++ b/surfsense_backend/app/services/gmail/tool_metadata_service.py
@@ -216,13 +216,13 @@ class GmailToolMetadataService:
)
async def _get_accounts(
- self, search_space_id: int, user_id: str
+ self, workspace_id: int, user_id: str
) -> list[GmailAccount]:
result = await self._db_session.execute(
select(SearchSourceConnector)
.filter(
and_(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(
[
@@ -237,8 +237,8 @@ class GmailToolMetadataService:
connectors = result.scalars().all()
return [GmailAccount.from_connector(c) for c in connectors]
- async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
- accounts = await self._get_accounts(search_space_id, user_id)
+ async def get_creation_context(self, workspace_id: int, user_id: str) -> dict:
+ accounts = await self._get_accounts(workspace_id, user_id)
if not accounts:
return {
@@ -289,10 +289,10 @@ class GmailToolMetadataService:
return {"accounts": accounts_with_status}
async def get_update_context(
- self, search_space_id: int, user_id: str, email_ref: str
+ self, workspace_id: int, user_id: str, email_ref: str
) -> dict:
document, connector = await self._resolve_email(
- search_space_id, user_id, email_ref
+ workspace_id, user_id, email_ref
)
if not document or not connector:
@@ -478,10 +478,10 @@ class GmailToolMetadataService:
return text_content.strip() if text_content.strip() else None
async def get_trash_context(
- self, search_space_id: int, user_id: str, email_ref: str
+ self, workspace_id: int, user_id: str, email_ref: str
) -> dict:
document, connector = await self._resolve_email(
- search_space_id, user_id, email_ref
+ workspace_id, user_id, email_ref
)
if not document or not connector:
@@ -509,7 +509,7 @@ class GmailToolMetadataService:
}
async def _resolve_email(
- self, search_space_id: int, user_id: str, email_ref: str
+ self, workspace_id: int, user_id: str, email_ref: str
) -> tuple[Document | None, SearchSourceConnector | None]:
result = await self._db_session.execute(
select(Document, SearchSourceConnector)
@@ -519,7 +519,7 @@ class GmailToolMetadataService:
)
.filter(
and_(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type.in_(
[
DocumentType.GOOGLE_GMAIL_CONNECTOR,
diff --git a/surfsense_backend/app/services/google_calendar/kb_sync_service.py b/surfsense_backend/app/services/google_calendar/kb_sync_service.py
index 495720a2d..cab6302a2 100644
--- a/surfsense_backend/app/services/google_calendar/kb_sync_service.py
+++ b/surfsense_backend/app/services/google_calendar/kb_sync_service.py
@@ -40,7 +40,7 @@ class GoogleCalendarKBSyncService:
html_link: str | None,
description: str | None,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -52,7 +52,7 @@ class GoogleCalendarKBSyncService:
try:
unique_hash = generate_unique_identifier_hash(
- DocumentType.GOOGLE_CALENDAR_CONNECTOR, event_id, search_space_id
+ DocumentType.GOOGLE_CALENDAR_CONNECTOR, event_id, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -74,7 +74,7 @@ class GoogleCalendarKBSyncService:
f"{description or ''}"
).strip()
- content_hash = generate_content_hash(indexable_content, search_space_id)
+ content_hash = generate_content_hash(indexable_content, workspace_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
@@ -116,7 +116,7 @@ class GoogleCalendarKBSyncService:
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
source_markdown=indexable_content,
updated_at=get_current_timestamp(),
@@ -165,7 +165,7 @@ class GoogleCalendarKBSyncService:
document_id: int,
event_id: str,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -260,7 +260,7 @@ class GoogleCalendarKBSyncService:
document.title = event_summary
document.content = summary_content
document.content_hash = generate_content_hash(
- indexable_content, search_space_id
+ indexable_content, workspace_id
)
document.embedding = summary_embedding
diff --git a/surfsense_backend/app/services/google_calendar/tool_metadata_service.py b/surfsense_backend/app/services/google_calendar/tool_metadata_service.py
index 7e50ab039..2037f940b 100644
--- a/surfsense_backend/app/services/google_calendar/tool_metadata_service.py
+++ b/surfsense_backend/app/services/google_calendar/tool_metadata_service.py
@@ -240,13 +240,13 @@ class GoogleCalendarToolMetadataService:
)
async def _get_accounts(
- self, search_space_id: int, user_id: str
+ self, workspace_id: int, user_id: str
) -> list[GoogleCalendarAccount]:
result = await self._db_session.execute(
select(SearchSourceConnector)
.filter(
and_(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(CALENDAR_CONNECTOR_TYPES),
)
@@ -256,8 +256,8 @@ class GoogleCalendarToolMetadataService:
connectors = result.scalars().all()
return [GoogleCalendarAccount.from_connector(c) for c in connectors]
- async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
- accounts = await self._get_accounts(search_space_id, user_id)
+ async def get_creation_context(self, workspace_id: int, user_id: str) -> dict:
+ accounts = await self._get_accounts(workspace_id, user_id)
if not accounts:
return {
@@ -360,9 +360,9 @@ class GoogleCalendarToolMetadataService:
}
async def get_update_context(
- self, search_space_id: int, user_id: str, event_ref: str
+ self, workspace_id: int, user_id: str, event_ref: str
) -> dict:
- resolved = await self._resolve_event(search_space_id, user_id, event_ref)
+ resolved = await self._resolve_event(workspace_id, user_id, event_ref)
if not resolved:
return {
"error": (
@@ -449,12 +449,12 @@ class GoogleCalendarToolMetadataService:
}
async def get_deletion_context(
- self, search_space_id: int, user_id: str, event_ref: str
+ self, workspace_id: int, user_id: str, event_ref: str
) -> dict:
- resolved = await self._resolve_event(search_space_id, user_id, event_ref)
+ resolved = await self._resolve_event(workspace_id, user_id, event_ref)
if not resolved:
live_resolved = await self._resolve_live_event(
- search_space_id, user_id, event_ref
+ workspace_id, user_id, event_ref
)
if not live_resolved:
return {
@@ -495,7 +495,7 @@ class GoogleCalendarToolMetadataService:
}
async def _resolve_event(
- self, search_space_id: int, user_id: str, event_ref: str
+ self, workspace_id: int, user_id: str, event_ref: str
) -> tuple[Document, SearchSourceConnector] | None:
result = await self._db_session.execute(
select(Document, SearchSourceConnector)
@@ -505,7 +505,7 @@ class GoogleCalendarToolMetadataService:
)
.filter(
and_(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type.in_(CALENDAR_DOCUMENT_TYPES),
SearchSourceConnector.user_id == user_id,
or_(
@@ -526,13 +526,13 @@ class GoogleCalendarToolMetadataService:
return None
async def _resolve_live_event(
- self, search_space_id: int, user_id: str, event_ref: str
+ self, workspace_id: int, user_id: str, event_ref: str
) -> tuple[SearchSourceConnector, dict] | None:
result = await self._db_session.execute(
select(SearchSourceConnector)
.filter(
and_(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(CALENDAR_CONNECTOR_TYPES),
)
diff --git a/surfsense_backend/app/services/google_drive/kb_sync_service.py b/surfsense_backend/app/services/google_drive/kb_sync_service.py
index 30fbc14f2..4cf93c228 100644
--- a/surfsense_backend/app/services/google_drive/kb_sync_service.py
+++ b/surfsense_backend/app/services/google_drive/kb_sync_service.py
@@ -26,7 +26,7 @@ class GoogleDriveKBSyncService:
web_view_link: str | None,
content: str | None,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -38,7 +38,7 @@ class GoogleDriveKBSyncService:
try:
unique_hash = generate_unique_identifier_hash(
- DocumentType.GOOGLE_DRIVE_FILE, file_id, search_space_id
+ DocumentType.GOOGLE_DRIVE_FILE, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -58,7 +58,7 @@ class GoogleDriveKBSyncService:
f"Google Drive file: {file_name} (type: {mime_type})"
)
- content_hash = generate_content_hash(indexable_content, search_space_id)
+ content_hash = generate_content_hash(indexable_content, workspace_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
@@ -95,7 +95,7 @@ class GoogleDriveKBSyncService:
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
source_markdown=content,
updated_at=get_current_timestamp(),
diff --git a/surfsense_backend/app/services/google_drive/tool_metadata_service.py b/surfsense_backend/app/services/google_drive/tool_metadata_service.py
index 0f654bc78..1be8606f6 100644
--- a/surfsense_backend/app/services/google_drive/tool_metadata_service.py
+++ b/surfsense_backend/app/services/google_drive/tool_metadata_service.py
@@ -103,8 +103,8 @@ class GoogleDriveToolMetadataService:
return inner, None
return data, None
- async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
- accounts = await self._get_google_drive_accounts(search_space_id, user_id)
+ async def get_creation_context(self, workspace_id: int, user_id: str) -> dict:
+ accounts = await self._get_google_drive_accounts(workspace_id, user_id)
if not accounts:
return {
@@ -132,7 +132,7 @@ class GoogleDriveToolMetadataService:
}
async def get_trash_context(
- self, search_space_id: int, user_id: str, file_name: str
+ self, workspace_id: int, user_id: str, file_name: str
) -> dict:
result = await self._db_session.execute(
select(Document)
@@ -141,7 +141,7 @@ class GoogleDriveToolMetadataService:
)
.filter(
and_(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.GOOGLE_DRIVE_FILE,
func.lower(Document.title) == func.lower(file_name),
SearchSourceConnector.user_id == user_id,
@@ -198,13 +198,13 @@ class GoogleDriveToolMetadataService:
}
async def _get_google_drive_accounts(
- self, search_space_id: int, user_id: str
+ self, workspace_id: int, user_id: str
) -> list[GoogleDriveAccount]:
result = await self._db_session.execute(
select(SearchSourceConnector)
.filter(
and_(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(
[
diff --git a/surfsense_backend/app/services/linear/kb_sync_service.py b/surfsense_backend/app/services/linear/kb_sync_service.py
index 3b8def6c3..b267428ec 100644
--- a/surfsense_backend/app/services/linear/kb_sync_service.py
+++ b/surfsense_backend/app/services/linear/kb_sync_service.py
@@ -34,7 +34,7 @@ class LinearKBSyncService:
issue_url: str | None,
description: str | None,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -46,7 +46,7 @@ class LinearKBSyncService:
try:
unique_hash = generate_unique_identifier_hash(
- DocumentType.LINEAR_CONNECTOR, issue_id, search_space_id
+ DocumentType.LINEAR_CONNECTOR, issue_id, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -68,7 +68,7 @@ class LinearKBSyncService:
f"# {issue_identifier}: {issue_title}\n\n{indexable_content}"
)
- content_hash = generate_content_hash(issue_content, search_space_id)
+ content_hash = generate_content_hash(issue_content, workspace_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
@@ -107,7 +107,7 @@ class LinearKBSyncService:
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@@ -155,7 +155,7 @@ class LinearKBSyncService:
document_id: int,
issue_id: str,
user_id: str,
- search_space_id: int,
+ workspace_id: int,
) -> dict:
"""Re-index a Linear issue document after it has been updated via the API.
@@ -163,7 +163,7 @@ class LinearKBSyncService:
document_id: The KB document ID to update.
issue_id: The Linear issue UUID to fetch fresh content from.
user_id: Used to select the correct LLM configuration.
- search_space_id: Used to select the correct LLM configuration.
+ workspace_id: Used to select the correct LLM configuration.
Returns:
dict with 'status': 'success' | 'not_indexed' | 'error'.
@@ -213,9 +213,7 @@ class LinearKBSyncService:
document.title = f"{issue_identifier}: {issue_title}"
document.content = summary_content
- document.content_hash = generate_content_hash(
- issue_content, search_space_id
- )
+ document.content_hash = generate_content_hash(issue_content, workspace_id)
document.embedding = summary_embedding
from sqlalchemy.orm.attributes import flag_modified
diff --git a/surfsense_backend/app/services/linear/tool_metadata_service.py b/surfsense_backend/app/services/linear/tool_metadata_service.py
index 9848534b0..10033454e 100644
--- a/surfsense_backend/app/services/linear/tool_metadata_service.py
+++ b/surfsense_backend/app/services/linear/tool_metadata_service.py
@@ -90,7 +90,7 @@ class LinearToolMetadataService:
def __init__(self, db_session: AsyncSession):
self._db_session = db_session
- async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
+ async def get_creation_context(self, workspace_id: int, user_id: str) -> dict:
"""Return context needed to create a new Linear issue.
Fetches all connected Linear workspaces, and for each one fetches
@@ -99,7 +99,7 @@ class LinearToolMetadataService:
Returns a dict with key: workspaces (each entry has id, name, organization_name, teams, priorities).
Returns a dict with key 'error' on failure.
"""
- connectors = await self._get_all_linear_connectors(search_space_id, user_id)
+ connectors = await self._get_all_linear_connectors(workspace_id, user_id)
if not connectors:
return {"error": "No Linear account connected"}
@@ -155,7 +155,7 @@ class LinearToolMetadataService:
return {"workspaces": workspaces}
async def get_update_context(
- self, search_space_id: int, user_id: str, issue_ref: str
+ self, workspace_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to update an indexed Linear issue.
@@ -166,7 +166,7 @@ class LinearToolMetadataService:
Returns a dict with keys: workspace, priorities, issue, team.
Returns a dict with key 'error' if the issue is not found or API fails.
"""
- document = await self._resolve_issue(search_space_id, user_id, issue_ref)
+ document = await self._resolve_issue(workspace_id, user_id, issue_ref)
if not document:
return {
"error": f"Issue '{issue_ref}' not found in your synced Linear issues. "
@@ -241,7 +241,7 @@ class LinearToolMetadataService:
}
async def get_delete_context(
- self, search_space_id: int, user_id: str, issue_ref: str
+ self, workspace_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to archive an indexed Linear issue.
@@ -250,7 +250,7 @@ class LinearToolMetadataService:
Returns a dict with keys: workspace, issue.
Returns a dict with key 'error' if the issue is not found.
"""
- document = await self._resolve_issue(search_space_id, user_id, issue_ref)
+ document = await self._resolve_issue(workspace_id, user_id, issue_ref)
if not document:
return {
"error": f"Issue '{issue_ref}' not found in your synced Linear issues. "
@@ -332,7 +332,7 @@ class LinearToolMetadataService:
return result.get("data", {}).get("issue")
async def _resolve_issue(
- self, search_space_id: int, user_id: str, issue_ref: str
+ self, workspace_id: int, user_id: str, issue_ref: str
) -> Document | None:
"""Resolve an issue from the KB using a 3-step fallback.
@@ -348,7 +348,7 @@ class LinearToolMetadataService:
)
.filter(
and_(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.LINEAR_CONNECTOR,
SearchSourceConnector.user_id == user_id,
or_(
@@ -368,13 +368,13 @@ class LinearToolMetadataService:
return result.scalars().first()
async def _get_all_linear_connectors(
- self, search_space_id: int, user_id: str
+ self, workspace_id: int, user_id: str
) -> list[SearchSourceConnector]:
- """Fetch all Linear connectors for the given search space and user."""
+ """Fetch all Linear connectors for the given workspace and user."""
result = await self._db_session.execute(
select(SearchSourceConnector).filter(
and_(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LINEAR_CONNECTOR,
diff --git a/surfsense_backend/app/services/llm_service.py b/surfsense_backend/app/services/llm_service.py
index e535d0150..6dbc6e6f7 100644
--- a/surfsense_backend/app/services/llm_service.py
+++ b/surfsense_backend/app/services/llm_service.py
@@ -9,7 +9,7 @@ from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from app.config import config
-from app.db import Model, SearchSpace
+from app.db import Model, Workspace
from app.services.auto_model_pin_service import (
auto_model_candidates,
choose_auto_model_candidate,
@@ -150,7 +150,7 @@ def _chat_litellm_from_resolved(
async def _get_db_model(
session: AsyncSession,
model_id: int,
- search_space: SearchSpace,
+ workspace: Workspace,
) -> Model | None:
result = await session.execute(
select(Model)
@@ -161,9 +161,9 @@ async def _get_db_model(
if not model or not model.connection or not model.connection.enabled:
return None
conn = model.connection
- if conn.search_space_id and conn.search_space_id != search_space.id:
+ if conn.workspace_id and conn.workspace_id != workspace.id:
return None
- if conn.user_id and conn.user_id != search_space.user_id:
+ if conn.user_id and conn.user_id != workspace.user_id:
return None
return model
@@ -269,48 +269,48 @@ async def validate_llm_config(
return False, error_msg
-async def get_search_space_llm_instance(
+async def get_workspace_llm_instance(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
role: str,
disable_streaming: bool = False,
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
"""
- Get a ChatLiteLLM instance for a specific search space and role.
+ Get a ChatLiteLLM instance for a specific workspace and role.
- LLM preferences are stored at the search space level and shared by all members.
+ LLM preferences are stored at the workspace level and shared by all members.
If Auto mode (ID 0) is configured, returns a ChatLiteLLMRouter that uses
LiteLLM Router for automatic load balancing across available providers.
Args:
session: Database session
- search_space_id: Search Space ID
+ workspace_id: Workspace ID
role: LLM role ('agent')
Returns:
ChatLiteLLM or ChatLiteLLMRouter instance, or None if not found
"""
try:
- # Get the search space with its LLM preferences
+ # Get the workspace with its LLM preferences
result = await session.execute(
- select(SearchSpace).where(SearchSpace.id == search_space_id)
+ select(Workspace).where(Workspace.id == workspace_id)
)
- search_space = result.scalars().first()
+ workspace = result.scalars().first()
- if not search_space:
- logger.error(f"Search space {search_space_id} not found")
+ if not workspace:
+ logger.error(f"Workspace {workspace_id} not found")
return None
# Get the appropriate model binding ID based on role
if role == LLMRole.AGENT:
- llm_config_id = search_space.chat_model_id
+ llm_config_id = workspace.chat_model_id
else:
logger.error(f"Invalid LLM role: {role}")
return None
if llm_config_id is None:
- logger.error(f"No {role} LLM configured for search space {search_space_id}")
+ logger.error(f"No {role} LLM configured for workspace {workspace_id}")
return None
# Auto mode resolves to one concrete global or BYOK model from the
@@ -318,15 +318,15 @@ async def get_search_space_llm_instance(
if is_auto_mode(llm_config_id):
candidates = await auto_model_candidates(
session,
- search_space_id=search_space_id,
- user_id=search_space.user_id,
+ workspace_id=workspace_id,
+ user_id=workspace.user_id,
capability="chat",
)
if not candidates:
logger.error("No chat-capable models available for Auto mode")
return None
llm_config_id = int(
- choose_auto_model_candidate(candidates, search_space_id)["id"]
+ choose_auto_model_candidate(candidates, workspace_id)["id"]
)
# Check if this is a global virtual model (negative ID)
@@ -356,10 +356,10 @@ async def get_search_space_llm_instance(
return SanitizedChatLiteLLM(**litellm_kwargs)
- model = await _get_db_model(session, llm_config_id, search_space)
+ model = await _get_db_model(session, llm_config_id, workspace)
if not model or not _has_capability(model, "chat"):
logger.error(
- f"Chat model {llm_config_id} not found in search space {search_space_id}"
+ f"Chat model {llm_config_id} not found in workspace {workspace_id}"
)
return None
@@ -377,27 +377,27 @@ async def get_search_space_llm_instance(
except Exception as e:
logger.error(
- f"Error getting LLM instance for search space {search_space_id}, role {role}: {e!s}"
+ f"Error getting LLM instance for workspace {workspace_id}, role {role}: {e!s}"
)
return None
async def get_agent_llm(
- session: AsyncSession, search_space_id: int, disable_streaming: bool = False
+ session: AsyncSession, workspace_id: int, disable_streaming: bool = False
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
- """Get the search space's chat model instance."""
- return await get_search_space_llm_instance(
+ """Get the workspace's chat model instance."""
+ return await get_workspace_llm_instance(
session,
- search_space_id,
+ workspace_id,
LLMRole.AGENT,
disable_streaming=disable_streaming,
)
async def get_vision_llm(
- session: AsyncSession, search_space_id: int
+ session: AsyncSession, workspace_id: int
) -> ChatLiteLLM | ChatLiteLLMRouter | None:
- """Get the search space's vision LLM instance for screenshot analysis.
+ """Get the workspace's vision LLM instance for screenshot analysis.
Resolves from the new connection/model role bindings:
- Auto mode (ID 0): unified global/BYOK model candidate selection
@@ -405,7 +405,7 @@ async def get_vision_llm(
- DB (positive ID): Model + Connection tables
Premium global configs are wrapped in :class:`QuotaCheckedVisionLLM`
- so each ``ainvoke`` debits the search-space owner's premium credit
+ so each ``ainvoke`` debits the workspace owner's premium credit
pool. User-owned BYOK configs and free global configs are returned
unwrapped — they don't consume premium credit (issue M).
"""
@@ -413,17 +413,17 @@ async def get_vision_llm(
try:
result = await session.execute(
- select(SearchSpace).where(SearchSpace.id == search_space_id)
+ select(Workspace).where(Workspace.id == workspace_id)
)
- search_space = result.scalars().first()
- if not search_space:
- logger.error(f"Search space {search_space_id} not found")
+ workspace = result.scalars().first()
+ if not workspace:
+ logger.error(f"Workspace {workspace_id} not found")
return None
- owner_user_id = search_space.user_id
+ owner_user_id = workspace.user_id
# Prefer the selected chat model when it is vision-capable.
- chat_model_id = search_space.chat_model_id
+ chat_model_id = workspace.chat_model_id
if chat_model_id and chat_model_id != AUTO_MODE_ID:
if chat_model_id < 0:
chat_model = get_global_model(chat_model_id)
@@ -442,7 +442,7 @@ async def get_vision_llm(
return SanitizedChatLiteLLM(**litellm_kwargs)
else:
- chat_model = await _get_db_model(session, chat_model_id, search_space)
+ chat_model = await _get_db_model(session, chat_model_id, workspace)
if chat_model and _has_capability(chat_model, "vision"):
_, litellm_kwargs = _chat_litellm_from_resolved(
conn=chat_model.connection,
@@ -454,24 +454,22 @@ async def get_vision_llm(
return SanitizedChatLiteLLM(**litellm_kwargs)
- config_id = search_space.vision_model_id
+ config_id = workspace.vision_model_id
if config_id is None:
- logger.error(f"No vision LLM configured for search space {search_space_id}")
+ logger.error(f"No vision LLM configured for workspace {workspace_id}")
return None
if config_id == AUTO_MODE_ID:
candidates = await auto_model_candidates(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=owner_user_id,
capability="vision",
)
if not candidates:
logger.error("No vision-capable models available for Auto mode")
return None
- config_id = int(
- choose_auto_model_candidate(candidates, search_space_id)["id"]
- )
+ config_id = int(choose_auto_model_candidate(candidates, workspace_id)["id"])
if config_id < 0:
global_model = get_global_model(config_id)
@@ -504,7 +502,7 @@ async def get_vision_llm(
return QuotaCheckedVisionLLM(
inner_llm,
user_id=owner_user_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
billing_tier=billing_tier,
base_model=model_string,
quota_reserve_tokens=global_model.get("catalog", {}).get(
@@ -513,10 +511,10 @@ async def get_vision_llm(
)
return inner_llm
- model = await _get_db_model(session, config_id, search_space)
+ model = await _get_db_model(session, config_id, workspace)
if not model or not _has_capability(model, "vision"):
logger.error(
- f"Vision model {config_id} not found in search space {search_space_id}"
+ f"Vision model {config_id} not found in workspace {workspace_id}"
)
return None
@@ -532,9 +530,7 @@ async def get_vision_llm(
return SanitizedChatLiteLLM(**litellm_kwargs)
except Exception as e:
- logger.error(
- f"Error getting vision LLM for search space {search_space_id}: {e!s}"
- )
+ logger.error(f"Error getting vision LLM for workspace {workspace_id}: {e!s}")
return None
@@ -551,7 +547,7 @@ def get_planner_llm() -> ChatLiteLLM | None:
This helper reads from ``config.GLOBAL_LLM_CONFIGS`` (loaded at import
time from ``global_llm_config.yaml``) so it has no DB cost and can be
called synchronously from middleware/factory code. It returns the same
- instance shape as the global path of ``get_search_space_llm_instance``.
+ instance shape as the global path of ``get_workspace_llm_instance``.
Callers MUST fall back to their chat LLM when this returns ``None`` so
deployments without a planner config keep working unchanged.
diff --git a/surfsense_backend/app/services/mcp_oauth/registry.py b/surfsense_backend/app/services/mcp_oauth/registry.py
index 310c3f6e8..c61aedfd6 100644
--- a/surfsense_backend/app/services/mcp_oauth/registry.py
+++ b/surfsense_backend/app/services/mcp_oauth/registry.py
@@ -153,13 +153,26 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
"mpim:history",
"im:history",
],
+ # Both prefixed and unprefixed variants: sources disagree on which
+ # names mcp.slack.com currently advertises. Unmatched entries are
+ # ignored at discovery time.
allowed_tools=[
"slack_search_channels",
"slack_read_channel",
"slack_read_thread",
+ "search_channels",
+ "read_channel",
+ "read_thread",
],
readonly_tools=frozenset(
- {"slack_search_channels", "slack_read_channel", "slack_read_thread"}
+ {
+ "slack_search_channels",
+ "slack_read_channel",
+ "slack_read_thread",
+ "search_channels",
+ "read_channel",
+ "read_thread",
+ }
),
# TODO: oauth.v2.user.access only returns team.id, not team.name.
# To populate team_name, either add "team:read" scope and call
@@ -185,6 +198,53 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
),
account_metadata_keys=["user_id", "user_email"],
),
+ "notion": MCPServiceConfig(
+ name="Notion",
+ mcp_url="https://mcp.notion.com/mcp",
+ connector_type="NOTION_CONNECTOR",
+ # DCR (RFC 7591): Notion issues its own client credentials. It expires
+ # DCR registrations, but refresh reuses the original persisted
+ # ``mcp_oauth.client_id`` (see _refresh_connector_token).
+ # Notion renamed its MCP tools with a "notion-" prefix (late 2025).
+ # Unprefixed names kept for servers still advertising the old names —
+ # allowlist entries the server doesn't advertise are simply ignored.
+ allowed_tools=[
+ "notion-search",
+ "notion-fetch",
+ "notion-create-pages",
+ "notion-update-page",
+ "search",
+ "fetch",
+ "create-pages",
+ "update-page",
+ ],
+ readonly_tools=frozenset({"notion-search", "notion-fetch", "search", "fetch"}),
+ account_metadata_keys=["workspace_name"],
+ ),
+ "confluence": MCPServiceConfig(
+ name="Confluence",
+ # Same Atlassian Rovo server as Jira; tool sets are kept disjoint by
+ # curation so a workspace can connect both as separate connectors.
+ mcp_url="https://mcp.atlassian.com/v1/mcp",
+ connector_type="CONFLUENCE_CONNECTOR",
+ allowed_tools=[
+ "getAccessibleAtlassianResources",
+ "getConfluenceSpaces",
+ "getConfluencePage",
+ "searchConfluenceUsingCql",
+ "createConfluencePage",
+ "updateConfluencePage",
+ ],
+ readonly_tools=frozenset(
+ {
+ "getAccessibleAtlassianResources",
+ "getConfluenceSpaces",
+ "getConfluencePage",
+ "searchConfluenceUsingCql",
+ }
+ ),
+ account_metadata_keys=["cloud_id", "site_name", "base_url"],
+ ),
}
_CONNECTOR_TYPE_TO_SERVICE: dict[str, MCPServiceConfig] = {
@@ -205,6 +265,25 @@ LIVE_CONNECTOR_TYPES: frozenset[SearchSourceConnectorType] = frozenset(
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
SearchSourceConnectorType.DISCORD_CONNECTOR,
SearchSourceConnectorType.LUMA_CONNECTOR,
+ # Migrated to hosted MCP — indexing pipelines deprecated (KB is
+ # files/notes/uploads only). LIVE membership blocks the index route
+ # and auto-disables periodic indexing.
+ SearchSourceConnectorType.NOTION_CONNECTOR,
+ SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
+ }
+)
+
+# Indexing-only connectors retired with the KB "files, notes, and uploads only"
+# shift: their ingestion pipelines are deprecated. Like LIVE membership, this
+# blocks the index route and auto-disables periodic indexing — but the message
+# frames it as a deprecation, not a real-time-tools swap. Obsidian is
+# intentionally excluded (file-like vault content still enriches the KB).
+DEPRECATED_INDEXING_CONNECTOR_TYPES: frozenset[SearchSourceConnectorType] = frozenset(
+ {
+ SearchSourceConnectorType.GITHUB_CONNECTOR,
+ SearchSourceConnectorType.BOOKSTACK_CONNECTOR,
+ SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR,
+ SearchSourceConnectorType.CIRCLEBACK_CONNECTOR,
}
)
diff --git a/surfsense_backend/app/services/memory/service.py b/surfsense_backend/app/services/memory/service.py
index feca000c9..b25522c10 100644
--- a/surfsense_backend/app/services/memory/service.py
+++ b/surfsense_backend/app/services/memory/service.py
@@ -11,7 +11,7 @@ from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db import SearchSpace, User
+from app.db import User, Workspace
from app.services.memory.document import parse_memory_document, render_memory_document
from app.services.memory.rewrite import forced_rewrite
from app.services.memory.schemas import MemoryLimits
@@ -90,7 +90,7 @@ async def _load_target(
scope: MemoryScope | str,
target_id: str | int | UUID,
session: AsyncSession,
-) -> User | SearchSpace | None:
+) -> User | Workspace | None:
normalized = _normalize_scope(scope)
if normalized is MemoryScope.USER:
result = await session.execute(
@@ -98,18 +98,18 @@ async def _load_target(
)
return result.scalars().first()
result = await session.execute(
- select(SearchSpace).where(SearchSpace.id == int(target_id))
+ select(Workspace).where(Workspace.id == int(target_id))
)
return result.scalars().first()
-def _get_memory(target: User | SearchSpace, scope: MemoryScope) -> str:
+def _get_memory(target: User | Workspace, scope: MemoryScope) -> str:
if scope is MemoryScope.USER:
return getattr(target, "memory_md", None) or ""
return getattr(target, "shared_memory_md", None) or ""
-def _set_memory(target: User | SearchSpace, scope: MemoryScope, content: str) -> None:
+def _set_memory(target: User | Workspace, scope: MemoryScope, content: str) -> None:
if scope is MemoryScope.USER:
target.memory_md = content
else:
@@ -150,7 +150,7 @@ async def save_memory(
status="error",
message="User not found."
if normalized is MemoryScope.USER
- else "Search space not found.",
+ else "Workspace not found.",
)
old_memory = _get_memory(target, normalized)
diff --git a/surfsense_backend/app/services/notion/kb_sync_service.py b/surfsense_backend/app/services/notion/kb_sync_service.py
index ee85daf41..e9d81ac4f 100644
--- a/surfsense_backend/app/services/notion/kb_sync_service.py
+++ b/surfsense_backend/app/services/notion/kb_sync_service.py
@@ -25,7 +25,7 @@ class NotionKBSyncService:
page_url: str | None,
content: str | None,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -37,7 +37,7 @@ class NotionKBSyncService:
try:
unique_hash = generate_unique_identifier_hash(
- DocumentType.NOTION_CONNECTOR, page_id, search_space_id
+ DocumentType.NOTION_CONNECTOR, page_id, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -57,7 +57,7 @@ class NotionKBSyncService:
markdown_content = f"# Notion Page: {page_title}\n\n{indexable_content}"
- content_hash = generate_content_hash(markdown_content, search_space_id)
+ content_hash = generate_content_hash(markdown_content, workspace_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
@@ -93,7 +93,7 @@ class NotionKBSyncService:
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@@ -141,7 +141,7 @@ class NotionKBSyncService:
document_id: int,
appended_content: str,
user_id: str,
- search_space_id: int,
+ workspace_id: int,
appended_block_ids: list[str] | None = None,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -233,7 +233,7 @@ class NotionKBSyncService:
logger.debug("Updating document fields")
document.content = summary_content
- document.content_hash = generate_content_hash(full_content, search_space_id)
+ document.content_hash = generate_content_hash(full_content, workspace_id)
document.embedding = summary_embedding
document.document_metadata = {
**document.document_metadata,
diff --git a/surfsense_backend/app/services/notion/tool_metadata_service.py b/surfsense_backend/app/services/notion/tool_metadata_service.py
index 19dc1fd89..e80e5889f 100644
--- a/surfsense_backend/app/services/notion/tool_metadata_service.py
+++ b/surfsense_backend/app/services/notion/tool_metadata_service.py
@@ -74,8 +74,8 @@ class NotionToolMetadataService:
def __init__(self, db_session: AsyncSession):
self._db_session = db_session
- async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
- accounts = await self._get_notion_accounts(search_space_id, user_id)
+ async def get_creation_context(self, workspace_id: int, user_id: str) -> dict:
+ accounts = await self._get_notion_accounts(workspace_id, user_id)
if not accounts:
return {
@@ -84,9 +84,7 @@ class NotionToolMetadataService:
"error": "No Notion accounts connected",
}
- parent_pages = await self._get_parent_pages_by_account(
- search_space_id, accounts
- )
+ parent_pages = await self._get_parent_pages_by_account(workspace_id, accounts)
accounts_with_status = []
for acc in accounts:
@@ -123,7 +121,7 @@ class NotionToolMetadataService:
}
async def get_update_context(
- self, search_space_id: int, user_id: str, page_title: str
+ self, workspace_id: int, user_id: str, page_title: str
) -> dict:
result = await self._db_session.execute(
select(Document)
@@ -132,7 +130,7 @@ class NotionToolMetadataService:
)
.filter(
and_(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.NOTION_CONNECTOR,
func.lower(Document.title) == func.lower(page_title),
SearchSourceConnector.user_id == user_id,
@@ -202,18 +200,18 @@ class NotionToolMetadataService:
}
async def get_delete_context(
- self, search_space_id: int, user_id: str, page_title: str
+ self, workspace_id: int, user_id: str, page_title: str
) -> dict:
- return await self.get_update_context(search_space_id, user_id, page_title)
+ return await self.get_update_context(workspace_id, user_id, page_title)
async def _get_notion_accounts(
- self, search_space_id: int, user_id: str
+ self, workspace_id: int, user_id: str
) -> list[NotionAccount]:
result = await self._db_session.execute(
select(SearchSourceConnector)
.filter(
and_(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.NOTION_CONNECTOR,
@@ -243,7 +241,7 @@ class NotionToolMetadataService:
return True
async def _get_parent_pages_by_account(
- self, search_space_id: int, accounts: list[NotionAccount]
+ self, workspace_id: int, accounts: list[NotionAccount]
) -> dict:
parent_pages = {}
@@ -252,7 +250,7 @@ class NotionToolMetadataService:
select(Document)
.filter(
and_(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.connector_id == account.id,
Document.document_type == DocumentType.NOTION_CONNECTOR,
)
diff --git a/surfsense_backend/app/services/obsidian_plugin_indexer.py b/surfsense_backend/app/services/obsidian_plugin_indexer.py
index cd05d7935..2462093f8 100644
--- a/surfsense_backend/app/services/obsidian_plugin_indexer.py
+++ b/surfsense_backend/app/services/obsidian_plugin_indexer.py
@@ -24,7 +24,7 @@ Design notes
The plugin's content hash and the backend's ``content_hash`` are computed
differently (plugin uses raw SHA-256 of the markdown body; backend salts
-with ``search_space_id``). We persist the plugin's hash in
+with ``workspace_id``). We persist the plugin's hash in
``document_metadata['plugin_content_hash']`` so the manifest endpoint can
return what the plugin sent — that's the only number the plugin can
compare without re-downloading content.
@@ -217,7 +217,7 @@ async def _resolve_attachment_vision_llm(
session: AsyncSession,
*,
connector: SearchSourceConnector,
- search_space_id: int,
+ workspace_id: int,
payload: NotePayload,
):
"""Match connector indexers: only fetch vision LLM for image attachments
@@ -231,7 +231,7 @@ async def _resolve_attachment_vision_llm(
from app.services.llm_service import get_vision_llm
- return await get_vision_llm(session, search_space_id)
+ return await get_vision_llm(session, workspace_id)
def _require_extracted_attachment_content(
@@ -253,7 +253,7 @@ def _require_extracted_attachment_content(
async def _find_existing_document(
session: AsyncSession,
*,
- search_space_id: int,
+ workspace_id: int,
vault_id: str,
path: str,
) -> Document | None:
@@ -261,7 +261,7 @@ async def _find_existing_document(
uid_hash = generate_unique_identifier_hash(
DocumentType.OBSIDIAN_CONNECTOR,
unique_id,
- search_space_id,
+ workspace_id,
)
result = await session.execute(
select(Document).where(Document.unique_identifier_hash == uid_hash)
@@ -287,11 +287,11 @@ async def upsert_note(
a skip-because-unchanged hit).
"""
vault_name: str = (connector.config or {}).get("vault_name") or "Vault"
- search_space_id = connector.search_space_id
+ workspace_id = connector.workspace_id
existing = await _find_existing_document(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
vault_id=payload.vault_id,
path=payload.path,
)
@@ -324,7 +324,7 @@ async def upsert_note(
vision_llm = await _resolve_attachment_vision_llm(
session,
connector=connector,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
payload=payload,
)
content_for_index, etl_meta = await _extract_binary_attachment_markdown(
@@ -353,7 +353,7 @@ async def upsert_note(
source_markdown=document_string,
unique_id=_vault_path_unique_id(payload.vault_id, payload.path),
document_type=DocumentType.OBSIDIAN_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector.id,
created_by_id=str(user_id),
metadata=metadata,
@@ -387,11 +387,11 @@ async def rename_note(
the new path).
"""
vault_name: str = (connector.config or {}).get("vault_name") or "Vault"
- search_space_id = connector.search_space_id
+ workspace_id = connector.workspace_id
existing = await _find_existing_document(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
vault_id=vault_id,
path=old_path,
)
@@ -402,7 +402,7 @@ async def rename_note(
new_uid_hash = generate_unique_identifier_hash(
DocumentType.OBSIDIAN_CONNECTOR,
new_unique_id,
- search_space_id,
+ workspace_id,
)
collision = await session.execute(
@@ -461,7 +461,7 @@ async def delete_note(
"""
existing = await _find_existing_document(
session,
- search_space_id=connector.search_space_id,
+ workspace_id=connector.workspace_id,
vault_id=vault_id,
path=path,
)
@@ -500,7 +500,7 @@ async def merge_obsidian_connectors(
return
target_vault_id = (target.config or {}).get("vault_id")
- target_search_space_id = target.search_space_id
+ target_workspace_id = target.workspace_id
if not target_vault_id:
raise RuntimeError("merge target is missing vault_id")
@@ -539,13 +539,13 @@ async def merge_obsidian_connectors(
new_uid_hash = generate_unique_identifier_hash(
DocumentType.OBSIDIAN_CONNECTOR,
new_unique_id,
- target_search_space_id,
+ target_workspace_id,
)
meta["vault_id"] = target_vault_id
meta["connector_id"] = target.id
doc.document_metadata = meta
doc.connector_id = target.id
- doc.search_space_id = target_search_space_id
+ doc.workspace_id = target_workspace_id
doc.unique_identifier_hash = new_uid_hash
target_paths.add(path)
@@ -571,7 +571,7 @@ async def get_manifest(
result = await session.execute(
select(Document).where(
and_(
- Document.search_space_id == connector.search_space_id,
+ Document.workspace_id == connector.workspace_id,
Document.connector_id == connector.id,
Document.document_type == DocumentType.OBSIDIAN_CONNECTOR,
)
diff --git a/surfsense_backend/app/services/onedrive/kb_sync_service.py b/surfsense_backend/app/services/onedrive/kb_sync_service.py
index 2bfea6ef4..468253188 100644
--- a/surfsense_backend/app/services/onedrive/kb_sync_service.py
+++ b/surfsense_backend/app/services/onedrive/kb_sync_service.py
@@ -27,7 +27,7 @@ class OneDriveKBSyncService:
web_url: str | None,
content: str | None,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> dict:
from app.tasks.connector_indexers.base import (
@@ -39,7 +39,7 @@ class OneDriveKBSyncService:
try:
unique_hash = compute_identifier_hash(
- DocumentType.ONEDRIVE_FILE.value, file_id, search_space_id
+ DocumentType.ONEDRIVE_FILE.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(
@@ -57,7 +57,7 @@ class OneDriveKBSyncService:
if not indexable_content:
indexable_content = f"OneDrive file: {file_name} (type: {mime_type})"
- content_hash = generate_content_hash(indexable_content, search_space_id)
+ content_hash = generate_content_hash(indexable_content, workspace_id)
with self.db_session.no_autoflush:
dup = await check_duplicate_document_by_hash(
@@ -94,7 +94,7 @@ class OneDriveKBSyncService:
content_hash=content_hash,
unique_identifier_hash=unique_hash,
embedding=summary_embedding,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
source_markdown=content,
updated_at=get_current_timestamp(),
diff --git a/surfsense_backend/app/services/platform_scrape_credit_service.py b/surfsense_backend/app/services/platform_scrape_credit_service.py
new file mode 100644
index 000000000..6f212190b
--- /dev/null
+++ b/surfsense_backend/app/services/platform_scrape_credit_service.py
@@ -0,0 +1,77 @@
+"""Charge the credit wallet per *item returned* by a platform-native scraper.
+
+Deliberately mirrors :class:`app.services.web_crawl_credit_service.WebCrawlCreditService`:
+a simple **gate -> pre-check -> post-charge** model (no reserve/finalize) — a
+scrape has no LLM token accumulator to settle against. The billable unit is one
+returned item (a Reddit post/comment, a SERP page, a Maps place/review, a
+YouTube video/comment); the per-item rate is passed in by the caller from the
+verb's config knob so this one service serves every platform meter.
+
+The price is **fully config-driven** — there is no hardcoded rate here. When
+``config.PLATFORM_SCRAPE_BILLING_ENABLED`` is False (the default for
+self-hosted / OSS installs) every check/charge is a no-op, preserving the prior
+effectively-free scraping behaviour.
+
+Wallet math (spendable / check / debit) is shared with the crawl biller via
+:mod:`app.services.wallet_credit`.
+"""
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import config
+from app.services import wallet_credit
+
+# One "out of credit" type across every per-unit biller; the capability doors
+# already catch exactly this one.
+from app.services.etl_credit_service import InsufficientCreditsError
+
+__all__ = ["InsufficientCreditsError", "PlatformScrapeCreditService"]
+
+
+class PlatformScrapeCreditService:
+ """Checks and charges the credit wallet for platform scraper items."""
+
+ def __init__(self, session: AsyncSession):
+ self.session = session
+
+ @staticmethod
+ def billing_enabled() -> bool:
+ return config.PLATFORM_SCRAPE_BILLING_ENABLED
+
+ @staticmethod
+ def items_to_micros(items: int, rate_micros: int) -> int:
+ """Convert an item count to USD micro-credits at ``rate_micros``/item."""
+ return int(items) * int(rate_micros)
+
+ async def check_credits(
+ self, user_id: str | UUID, estimated_items: int, rate_micros: int
+ ) -> None:
+ """Raise :class:`InsufficientCreditsError` if the wallet can't afford
+ ``estimated_items`` at ``rate_micros`` each.
+
+ No-op when platform billing is disabled. ``estimated_items`` is a safe
+ upper bound (the request's worst-case fan-out) so a passing pre-flight
+ guarantees the wallet can never go negative.
+ """
+ if not config.PLATFORM_SCRAPE_BILLING_ENABLED:
+ return
+ required = self.items_to_micros(estimated_items, rate_micros)
+ await wallet_credit.check_balance(self.session, user_id, required)
+
+ async def charge(
+ self, user_id: str | UUID, items: int, rate_micros: int
+ ) -> int | None:
+ """Debit the wallet for ``items`` returned at ``rate_micros`` each.
+
+ No-op when platform billing is disabled or ``items <= 0``. Returns the
+ new balance in micros, or ``None`` when nothing was charged.
+ """
+ if not config.PLATFORM_SCRAPE_BILLING_ENABLED:
+ return None
+ if items <= 0:
+ return None
+ return await wallet_credit.apply_debit(
+ self.session, user_id, self.items_to_micros(items, rate_micros)
+ )
diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py
index 11c57e969..88b9018de 100644
--- a/surfsense_backend/app/services/public_chat_service.py
+++ b/surfsense_backend/app/services/public_chat_service.py
@@ -31,10 +31,10 @@ from app.db import (
PodcastStatus,
PublicChatSnapshot,
Report,
- SearchSpaceMembership,
User,
VideoPresentation,
VideoPresentationStatus,
+ WorkspaceMembership,
)
from app.utils.rbac import check_permission
@@ -189,7 +189,7 @@ async def create_snapshot(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.PUBLIC_SHARING_CREATE.value,
"You don't have permission to create public share links",
)
@@ -450,7 +450,7 @@ async def list_snapshots_for_thread(
await check_permission(
session,
auth,
- thread.search_space_id,
+ thread.workspace_id,
Permission.PUBLIC_SHARING_VIEW.value,
"You don't have permission to view public share links",
)
@@ -476,18 +476,18 @@ async def list_snapshots_for_thread(
]
-async def list_snapshots_for_search_space(
+async def list_snapshots_for_workspace(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
auth: AuthContext,
) -> list[dict]:
- """List all public snapshots for a search space."""
+ """List all public snapshots for a workspace."""
from app.config import config
await check_permission(
session,
auth,
- search_space_id,
+ workspace_id,
Permission.PUBLIC_SHARING_VIEW.value,
"You don't have permission to view public share links",
)
@@ -495,7 +495,7 @@ async def list_snapshots_for_search_space(
result = await session.execute(
select(PublicChatSnapshot)
.join(NewChatThread, PublicChatSnapshot.thread_id == NewChatThread.id)
- .filter(NewChatThread.search_space_id == search_space_id)
+ .filter(NewChatThread.workspace_id == workspace_id)
.order_by(PublicChatSnapshot.created_at.desc())
)
snapshots = result.scalars().all()
@@ -556,7 +556,7 @@ async def delete_snapshot(
await check_permission(
session,
auth,
- snapshot.thread.search_space_id,
+ snapshot.thread.workspace_id,
Permission.PUBLIC_SHARING_DELETE.value,
"You don't have permission to delete public share links",
)
@@ -603,27 +603,27 @@ async def delete_affected_snapshots(
# =============================================================================
-async def get_user_default_search_space(
+async def get_user_default_workspace(
session: AsyncSession,
user_id: UUID,
) -> int | None:
"""
- Get user's default search space for cloning.
+ Get user's default workspace for cloning.
- Returns the first search space where user is owner, or None if not found.
+ Returns the first workspace where user is owner, or None if not found.
"""
result = await session.execute(
- select(SearchSpaceMembership)
+ select(WorkspaceMembership)
.filter(
- SearchSpaceMembership.user_id == user_id,
- SearchSpaceMembership.is_owner.is_(True),
+ WorkspaceMembership.user_id == user_id,
+ WorkspaceMembership.is_owner.is_(True),
)
.limit(1)
)
membership = result.scalars().first()
if membership:
- return membership.search_space_id
+ return membership.workspace_id
return None
@@ -650,10 +650,10 @@ async def clone_from_snapshot(
status_code=404, detail="Chat not found or no longer public"
)
- target_search_space_id = await get_user_default_search_space(session, user.id)
+ target_workspace_id = await get_user_default_workspace(session, user.id)
- if target_search_space_id is None:
- raise HTTPException(status_code=400, detail="No search space found for user")
+ if target_workspace_id is None:
+ raise HTTPException(status_code=400, detail="No workspace found for user")
data = snapshot.snapshot_data
messages_data = data.get("messages", [])
@@ -664,7 +664,7 @@ async def clone_from_snapshot(
title=data.get("title", "Cloned Chat"),
archived=False,
visibility=ChatVisibility.PRIVATE,
- search_space_id=target_search_space_id,
+ workspace_id=target_workspace_id,
created_by_id=user.id,
cloned_from_thread_id=snapshot.thread_id,
cloned_from_snapshot_id=snapshot.id,
@@ -726,7 +726,7 @@ async def clone_from_snapshot(
storage_key=podcast_info.get("storage_key"),
file_location=podcast_info.get("file_path"),
status=PodcastStatus.READY,
- search_space_id=target_search_space_id,
+ workspace_id=target_workspace_id,
thread_id=new_thread.id,
)
session.add(new_podcast)
@@ -754,7 +754,7 @@ async def clone_from_snapshot(
title=report_info.get("title", "Cloned Report"),
content=report_info.get("content"),
report_metadata=report_info.get("report_metadata"),
- search_space_id=target_search_space_id,
+ workspace_id=target_workspace_id,
thread_id=new_thread.id,
)
session.add(new_report)
@@ -783,7 +783,7 @@ async def clone_from_snapshot(
return {
"thread_id": new_thread.id,
- "search_space_id": target_search_space_id,
+ "workspace_id": target_workspace_id,
}
diff --git a/surfsense_backend/app/services/quota_checked_vision_llm.py b/surfsense_backend/app/services/quota_checked_vision_llm.py
index 0040e5a5b..99fa0c97c 100644
--- a/surfsense_backend/app/services/quota_checked_vision_llm.py
+++ b/surfsense_backend/app/services/quota_checked_vision_llm.py
@@ -18,7 +18,7 @@ Why a wrapper instead of plumbing ``user_id`` through every caller:
Dropbox, local-folder, file-processor, ETL pipeline) each calling
``parse_with_vision_llm(...)``. Adding a ``user_id`` argument to each is
invasive, error-prone, and easy for a future indexer to forget.
-* Per the design (issue M), we always debit the *search-space owner*, not
+* Per the design (issue M), we always debit the *workspace owner*, not
the triggering user, so ``user_id`` is fully derivable from the search
space the caller is already operating on. The wrapper captures it once
at construction time.
@@ -56,7 +56,7 @@ class QuotaCheckedVisionLLM:
inner_llm: Any,
*,
user_id: UUID,
- search_space_id: int,
+ workspace_id: int,
billing_tier: str,
base_model: str,
quota_reserve_tokens: int | None,
@@ -64,7 +64,7 @@ class QuotaCheckedVisionLLM:
) -> None:
self._inner = inner_llm
self._user_id = user_id
- self._search_space_id = search_space_id
+ self._workspace_id = workspace_id
self._billing_tier = billing_tier
self._base_model = base_model
self._quota_reserve_tokens = quota_reserve_tokens
@@ -81,7 +81,7 @@ class QuotaCheckedVisionLLM:
"""
async with billable_call(
user_id=self._user_id,
- search_space_id=self._search_space_id,
+ workspace_id=self._workspace_id,
billing_tier=self._billing_tier,
base_model=self._base_model,
quota_reserve_tokens=self._quota_reserve_tokens,
diff --git a/surfsense_backend/app/services/revert_service.py b/surfsense_backend/app/services/revert_service.py
index 0cb6cd092..3d2faf44e 100644
--- a/surfsense_backend/app/services/revert_service.py
+++ b/surfsense_backend/app/services/revert_service.py
@@ -121,7 +121,7 @@ def can_revert(
"""Return True iff the requester is allowed to revert this action.
The plan's rule: "requester must be the original `user_id` on the
- action, or hold the search-space admin role." Anonymous actions
+ action, or hold the workspace admin role." Anonymous actions
(``action.user_id is None``) can only be reverted by admins.
"""
if is_admin:
@@ -215,7 +215,7 @@ async def _restore_in_place_document(
if isinstance(revision.content_before, str):
doc.content_hash = generate_content_hash(
- revision.content_before, doc.search_space_id
+ revision.content_before, doc.workspace_id
)
virtual_path = await _virtual_path_from_snapshot(session, revision)
@@ -223,7 +223,7 @@ async def _restore_in_place_document(
doc.unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.NOTE,
virtual_path,
- doc.search_space_id,
+ doc.workspace_id,
)
chunks_before = revision.chunks_before
@@ -285,15 +285,15 @@ async def _reinsert_document_from_revision(
),
)
- search_space_id = revision.search_space_id
+ workspace_id = revision.workspace_id
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.NOTE,
virtual_path,
- search_space_id,
+ workspace_id,
)
collision = await session.execute(
select(Document.id).where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.unique_identifier_hash == unique_identifier_hash,
)
)
@@ -318,10 +318,10 @@ async def _reinsert_document_from_revision(
document_type=DocumentType.NOTE,
document_metadata=metadata,
content=content,
- content_hash=generate_content_hash(content, search_space_id),
+ content_hash=generate_content_hash(content, workspace_id),
unique_identifier_hash=unique_identifier_hash,
source_markdown=content,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
folder_id=revision.folder_id_before,
updated_at=datetime.now(UTC),
)
@@ -451,7 +451,7 @@ async def _reinsert_folder_from_revision(
name=revision.name_before,
parent_id=revision.parent_id_before,
position=revision.position_before,
- search_space_id=revision.search_space_id,
+ workspace_id=revision.workspace_id,
updated_at=datetime.now(UTC),
)
session.add(new_folder)
@@ -608,7 +608,7 @@ async def revert_action(
new_row = AgentActionLog(
thread_id=action.thread_id,
user_id=requester_user_id,
- search_space_id=action.search_space_id,
+ workspace_id=action.workspace_id,
turn_id=None,
message_id=None,
tool_name=f"_revert:{action.tool_name}",
diff --git a/surfsense_backend/app/services/task_dispatcher.py b/surfsense_backend/app/services/task_dispatcher.py
index 43957be03..b525f877b 100644
--- a/surfsense_backend/app/services/task_dispatcher.py
+++ b/surfsense_backend/app/services/task_dispatcher.py
@@ -16,7 +16,7 @@ class TaskDispatcher(Protocol):
document_id: int,
temp_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
use_vision_llm: bool = False,
processing_mode: str = "basic",
@@ -32,7 +32,7 @@ class CeleryTaskDispatcher:
document_id: int,
temp_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
use_vision_llm: bool = False,
processing_mode: str = "basic",
@@ -45,7 +45,7 @@ class CeleryTaskDispatcher:
document_id=document_id,
temp_path=temp_path,
filename=filename,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
diff --git a/surfsense_backend/app/services/task_logging_service.py b/surfsense_backend/app/services/task_logging_service.py
index 6ba9d0432..9b1ae23ab 100644
--- a/surfsense_backend/app/services/task_logging_service.py
+++ b/surfsense_backend/app/services/task_logging_service.py
@@ -13,9 +13,9 @@ logger = logging.getLogger(__name__)
class TaskLoggingService:
"""Service for logging background tasks using the database Log model"""
- def __init__(self, session: AsyncSession, search_space_id: int):
+ def __init__(self, session: AsyncSession, workspace_id: int):
self.session = session
- self.search_space_id = search_space_id
+ self.workspace_id = workspace_id
async def log_task_start(
self,
@@ -47,7 +47,7 @@ class TaskLoggingService:
message=message,
source=source,
log_metadata=log_metadata,
- search_space_id=self.search_space_id,
+ workspace_id=self.workspace_id,
)
self.session.add(log_entry)
@@ -232,7 +232,7 @@ class TaskLoggingService:
message=message,
source=source,
log_metadata=metadata or {},
- search_space_id=self.search_space_id,
+ workspace_id=self.workspace_id,
)
self.session.add(log_entry)
diff --git a/surfsense_backend/app/services/token_tracking_service.py b/surfsense_backend/app/services/token_tracking_service.py
index d1a29b82a..2b4ec4273 100644
--- a/surfsense_backend/app/services/token_tracking_service.py
+++ b/surfsense_backend/app/services/token_tracking_service.py
@@ -518,7 +518,7 @@ async def record_token_usage(
session: AsyncSession,
*,
usage_type: str,
- search_space_id: int,
+ workspace_id: int,
user_id: UUID,
prompt_tokens: int = 0,
completion_tokens: int = 0,
@@ -546,7 +546,7 @@ async def record_token_usage(
call_details=call_details,
thread_id=thread_id,
message_id=message_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
session.add(record)
diff --git a/surfsense_backend/app/services/user_tool_allowlist.py b/surfsense_backend/app/services/user_tool_allowlist.py
index 9b87fbdea..c0cf34394 100644
--- a/surfsense_backend/app/services/user_tool_allowlist.py
+++ b/surfsense_backend/app/services/user_tool_allowlist.py
@@ -1,6 +1,6 @@
"""User-scoped trusted-tools list backed by ``SearchSourceConnector.config``.
-Storage is per ``(user_id, search_space_id, connector_id)`` under
+Storage is per ``(user_id, workspace_id, connector_id)`` under
``connector.config['trusted_tools']``. The list only ever encodes
``allow`` decisions; coded ``deny`` rules cannot be overridden here.
"""
@@ -108,7 +108,7 @@ async def fetch_user_allowlist_rulesets(
session: AsyncSession,
*,
user_id: uuid.UUID,
- search_space_id: int,
+ workspace_id: int,
) -> dict[str, Ruleset]:
"""Project the user's trusted tools into per-subagent ``allow`` rulesets.
@@ -122,7 +122,7 @@ async def fetch_user_allowlist_rulesets(
SearchSourceConnector.config,
).where(
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
)
)
diff --git a/surfsense_backend/app/services/wallet_credit.py b/surfsense_backend/app/services/wallet_credit.py
new file mode 100644
index 000000000..cfac2a335
--- /dev/null
+++ b/surfsense_backend/app/services/wallet_credit.py
@@ -0,0 +1,104 @@
+"""Shared credit-wallet primitives for the flat-rate per-unit billers.
+
+Both :class:`app.services.web_crawl_credit_service.WebCrawlCreditService` and
+:class:`app.services.platform_scrape_credit_service.PlatformScrapeCreditService`
+follow the same gate -> pre-check -> post-charge model against the unified
+``User.credit_micros_*`` wallet. The wallet math lives here once instead of
+being copied per service:
+
+- :func:`spendable_micros` — ``balance - reserved`` (ungated by any flag)
+- :func:`check_balance` — raise :class:`InsufficientCreditsError` if short
+- :func:`apply_debit` — debit + commit + best-effort auto-reload
+
+``InsufficientCreditsError`` is re-exported from ``etl_credit_service`` so every
+per-unit biller (ETL, crawl, platform scrape) shares one "out of credit" type —
+the capability doors already catch exactly that one.
+"""
+
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.services.etl_credit_service import InsufficientCreditsError
+
+__all__ = [
+ "InsufficientCreditsError",
+ "apply_debit",
+ "check_balance",
+ "spendable_micros",
+]
+
+
+async def spendable_micros(session: AsyncSession, user_id: str | UUID) -> int:
+ """Raw ``balance - reserved`` read, **ungated** by any billing flag."""
+ from app.db import User
+
+ result = await session.execute(
+ select(User.credit_micros_balance, User.credit_micros_reserved).where(
+ User.id == user_id
+ )
+ )
+ row = result.first()
+ if not row:
+ raise ValueError(f"User with ID {user_id} not found")
+
+ balance, reserved = row
+ return balance - reserved
+
+
+async def check_balance(
+ session: AsyncSession, user_id: str | UUID, required_micros: int
+) -> None:
+ """Raise :class:`InsufficientCreditsError` if the wallet can't cover
+ ``required_micros``. Generic and **ungated** — the caller decides when at
+ least one relevant biller is enabled. No-op for a non-positive requirement.
+ """
+ if required_micros <= 0:
+ return
+ available = await spendable_micros(session, user_id)
+ if required_micros > available:
+ raise InsufficientCreditsError(
+ message=(
+ "This run would exceed your available credit. "
+ f"Available: ${available / 1_000_000:.2f}, "
+ f"estimated need: ${required_micros / 1_000_000:.2f}. "
+ "Add more credits to continue."
+ ),
+ balance_micros=available,
+ required_micros=required_micros,
+ )
+
+
+async def apply_debit(
+ session: AsyncSession, user_id: str | UUID, cost_micros: int
+) -> int | None:
+ """Debit ``cost_micros`` from the wallet and commit.
+
+ Flushes any audit row the caller staged before this, then fires a
+ best-effort auto-reload check. No-op for a non-positive cost; returns the
+ new balance in micros, or ``None`` when nothing was charged.
+ """
+ if cost_micros <= 0:
+ return None
+
+ from app.db import User
+
+ result = await session.execute(select(User).where(User.id == user_id))
+ user = result.unique().scalar_one_or_none()
+ if not user:
+ raise ValueError(f"User with ID {user_id} not found")
+
+ user.credit_micros_balance -= cost_micros
+ await session.commit()
+ await session.refresh(user)
+
+ # Best-effort: fire an auto-reload check if the balance dropped low.
+ try:
+ from app.services.auto_reload_service import maybe_trigger_auto_reload
+
+ await maybe_trigger_auto_reload(user_id)
+ except Exception:
+ pass
+
+ return user.credit_micros_balance
diff --git a/surfsense_backend/app/services/web_crawl_credit_service.py b/surfsense_backend/app/services/web_crawl_credit_service.py
new file mode 100644
index 000000000..9a6642bf9
--- /dev/null
+++ b/surfsense_backend/app/services/web_crawl_credit_service.py
@@ -0,0 +1,166 @@
+"""Service for charging the unified credit wallet per successful web crawl.
+
+Deliberately mirrors :class:`app.services.etl_credit_service.EtlCreditService`:
+a simple **gate -> pre-check -> post-charge** model (no reserve/finalize),
+because a crawl has no LLM token accumulator to settle against. The billable
+unit is one *successful* crawl (``CrawlOutcomeStatus.SUCCESS``).
+
+The price is **fully config-driven** — there is no hardcoded rate anywhere.
+``config.WEB_CRAWL_MICROS_PER_SUCCESS`` is the single source of truth (default
+``1000`` micro-USD == $1 / 1000 crawls); retune it via env + restart, no code
+change. When ``config.WEB_CRAWL_CREDIT_BILLING_ENABLED`` is False (the default
+for self-hosted / OSS installs) every check/charge is a no-op, preserving the
+prior effectively-free crawl behaviour.
+
+``billing_enabled()`` and ``successes_to_micros()`` are exposed as static
+helpers so the capability biller (``app/capabilities/core/billing.py``, the
+``web.crawl`` verb) can share the flag/price math when gating and charging a
+crawl run.
+"""
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import config
+
+# Wallet math (spendable / check / debit) is shared with the platform-scrape
+# biller — see app/services/wallet_credit.py.
+from app.services import wallet_credit
+
+# Reuse the ETL service's error type so callers (and tests) have one exception
+# to catch for "out of credit" across every per-unit wallet biller.
+from app.services.etl_credit_service import InsufficientCreditsError
+
+__all__ = ["InsufficientCreditsError", "WebCrawlCreditService"]
+
+
+class WebCrawlCreditService:
+ """Checks and charges the credit wallet for successful web crawls."""
+
+ def __init__(self, session: AsyncSession):
+ self.session = session
+
+ @staticmethod
+ def billing_enabled() -> bool:
+ return config.WEB_CRAWL_CREDIT_BILLING_ENABLED
+
+ @staticmethod
+ def successes_to_micros(successes: int) -> int:
+ """Convert a successful-crawl count to USD micro-credits.
+
+ Reads ``config.WEB_CRAWL_MICROS_PER_SUCCESS`` — the single, env-tunable
+ source of truth for crawl price.
+ """
+ return int(successes) * config.WEB_CRAWL_MICROS_PER_SUCCESS
+
+ @staticmethod
+ def captcha_billing_enabled() -> bool:
+ """Phase 3d: whether captcha *solves* are metered.
+
+ Independent of crawl billing: a deployment may bill solves without
+ billing crawls (or vice-versa). Off by default.
+ """
+ return config.WEB_CRAWL_CAPTCHA_BILLING_ENABLED
+
+ @staticmethod
+ def captcha_solves_to_micros(attempts: int) -> int:
+ """Convert a captcha *attempt* count to USD micro-credits.
+
+ Reads ``config.WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE`` (single source of
+ truth). Charged per attempt — not per success — because the solver
+ vendor bills every attempt regardless of crawl outcome.
+ """
+ return int(attempts) * config.WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE
+
+ async def _spendable_micros(self, user_id: str | UUID) -> int:
+ """Raw ``balance - reserved`` read, **ungated** by any billing flag.
+
+ Used by :meth:`check_balance` for combined (crawl + captcha) pre-flight,
+ where the relevant gate is decided by the caller, not by a single flag.
+ """
+ return await wallet_credit.spendable_micros(self.session, user_id)
+
+ async def get_available_micros(self, user_id: str | UUID) -> int | None:
+ """Return spendable credit in micro-USD (``balance - reserved``).
+
+ Returns ``None`` when crawl billing is disabled, which callers treat as
+ "unlimited" (no blocking, no charge).
+ """
+ if not config.WEB_CRAWL_CREDIT_BILLING_ENABLED:
+ return None
+ return await self._spendable_micros(user_id)
+
+ async def check_balance(self, user_id: str | UUID, required_micros: int) -> None:
+ """Raise :class:`InsufficientCreditsError` if the wallet can't cover
+ ``required_micros`` (a combined crawl + worst-case captcha estimate).
+
+ Generic and **ungated** — the caller computes ``required_micros`` from
+ whichever billers are enabled and only calls this when at least one is.
+ No-op for a non-positive requirement.
+ """
+ await wallet_credit.check_balance(self.session, user_id, required_micros)
+
+ async def check_credits(
+ self, user_id: str | UUID, estimated_successes: int = 1
+ ) -> None:
+ """Raise :class:`InsufficientCreditsError` if the user can't afford
+ ``estimated_successes`` crawls.
+
+ No-op when crawl billing is disabled. ``estimated_successes`` is a safe
+ upper bound (``len(urls)``) — actual successes are always <=, so a
+ passing pre-flight guarantees the wallet can never go negative.
+ """
+ if not config.WEB_CRAWL_CREDIT_BILLING_ENABLED:
+ return
+
+ required = self.successes_to_micros(estimated_successes)
+ available = await self.get_available_micros(user_id)
+ if available is None:
+ return
+
+ if required > available:
+ raise InsufficientCreditsError(
+ message=(
+ "This crawl would exceed your available credit. "
+ f"Available: ${available / 1_000_000:.2f}. "
+ f"Up to {estimated_successes} URL(s) cost about "
+ f"${required / 1_000_000:.2f}. Add more credits to continue."
+ ),
+ balance_micros=available,
+ required_micros=required,
+ )
+
+ async def _apply_debit(self, user_id: str | UUID, cost_micros: int) -> int | None:
+ """Debit ``cost_micros`` from the wallet and commit (shared by all
+ charge paths). Flushes any audit row the caller staged before this.
+
+ Mirrors ``EtlCreditService.charge_credits``' commit-then-refresh +
+ best-effort auto-reload. No-op for a non-positive cost.
+ """
+ return await wallet_credit.apply_debit(self.session, user_id, cost_micros)
+
+ async def charge_credits(self, user_id: str | UUID, successes: int) -> int | None:
+ """Debit the wallet for ``successes`` successful crawls.
+
+ No-op when crawl billing is disabled or ``successes <= 0``. Returns the
+ new balance in micros, or ``None`` when nothing was charged.
+ """
+ if not config.WEB_CRAWL_CREDIT_BILLING_ENABLED:
+ return None
+ if successes <= 0:
+ return None
+ return await self._apply_debit(user_id, self.successes_to_micros(successes))
+
+ async def charge_captcha(self, user_id: str | UUID, attempts: int) -> int | None:
+ """Debit the wallet for ``attempts`` captcha solves (Phase 3d).
+
+ Per-attempt (not per-success): the solver charges for every attempt even
+ when the crawl ultimately fails. No-op when captcha billing is disabled
+ or ``attempts <= 0``.
+ """
+ if not config.WEB_CRAWL_CAPTCHA_BILLING_ENABLED:
+ return None
+ if attempts <= 0:
+ return None
+ return await self._apply_debit(user_id, self.captcha_solves_to_micros(attempts))
diff --git a/surfsense_backend/app/services/web_search_service.py b/surfsense_backend/app/services/web_search_service.py
deleted file mode 100644
index a5c776323..000000000
--- a/surfsense_backend/app/services/web_search_service.py
+++ /dev/null
@@ -1,290 +0,0 @@
-"""
-Platform-level web search service backed by SearXNG.
-
-Redis is used only for result caching (graceful degradation if unavailable).
-The circuit breaker is fully in-process — no external dependency, zero
-latency overhead.
-"""
-
-from __future__ import annotations
-
-import contextlib
-import hashlib
-import json
-import logging
-import threading
-import time
-from typing import Any
-from urllib.parse import urljoin
-
-import httpx
-import redis
-
-from app.config import config
-
-logger = logging.getLogger(__name__)
-
-_EMPTY_RESULT: dict[str, Any] = {
- "id": 11,
- "name": "Web Search",
- "type": "SEARXNG_API",
- "sources": [],
-}
-
-# ---------------------------------------------------------------------------
-# Redis — used only for result caching
-# ---------------------------------------------------------------------------
-
-_redis_client: redis.Redis | None = None
-
-
-def _get_redis() -> redis.Redis:
- global _redis_client
- if _redis_client is None:
- _redis_client = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
- return _redis_client
-
-
-# ---------------------------------------------------------------------------
-# In-process Circuit Breaker (no Redis dependency)
-# ---------------------------------------------------------------------------
-
-_CB_FAILURE_THRESHOLD = 5
-_CB_FAILURE_WINDOW_SECONDS = 60
-_CB_COOLDOWN_SECONDS = 30
-
-_cb_lock = threading.Lock()
-_cb_failure_count: int = 0
-_cb_last_failure_time: float = 0.0
-_cb_open_until: float = 0.0
-
-
-def _circuit_is_open() -> bool:
- return time.monotonic() < _cb_open_until
-
-
-def _record_failure() -> None:
- global _cb_failure_count, _cb_last_failure_time, _cb_open_until
- now = time.monotonic()
- with _cb_lock:
- if now - _cb_last_failure_time > _CB_FAILURE_WINDOW_SECONDS:
- _cb_failure_count = 0
- _cb_failure_count += 1
- _cb_last_failure_time = now
- if _cb_failure_count >= _CB_FAILURE_THRESHOLD:
- _cb_open_until = now + _CB_COOLDOWN_SECONDS
- logger.warning(
- "Circuit breaker OPENED after %d failures — "
- "SearXNG calls paused for %ds",
- _cb_failure_count,
- _CB_COOLDOWN_SECONDS,
- )
-
-
-def _record_success() -> None:
- global _cb_failure_count, _cb_open_until
- with _cb_lock:
- _cb_failure_count = 0
- _cb_open_until = 0.0
-
-
-# ---------------------------------------------------------------------------
-# Result Caching (Redis, graceful degradation)
-# ---------------------------------------------------------------------------
-
-_CACHE_TTL_SECONDS = 300 # 5 minutes
-_CACHE_PREFIX = "websearch:cache:"
-
-
-def _cache_key(query: str, engines: str | None, language: str | None) -> str:
- raw = f"{query}|{engines or ''}|{language or ''}"
- digest = hashlib.sha256(raw.encode()).hexdigest()[:24]
- return f"{_CACHE_PREFIX}{digest}"
-
-
-def _cache_get(key: str) -> dict | None:
- try:
- data = _get_redis().get(key)
- if data:
- return json.loads(data)
- except (redis.RedisError, json.JSONDecodeError):
- pass
- return None
-
-
-def _cache_set(key: str, value: dict) -> None:
- with contextlib.suppress(redis.RedisError):
- _get_redis().setex(key, _CACHE_TTL_SECONDS, json.dumps(value))
-
-
-# ---------------------------------------------------------------------------
-# Public API
-# ---------------------------------------------------------------------------
-
-
-def is_available() -> bool:
- """Return ``True`` when the platform SearXNG host is configured."""
- return bool(config.SEARXNG_DEFAULT_HOST)
-
-
-async def health_check() -> dict[str, Any]:
- """Ping the SearXNG ``/healthz`` endpoint and return status info."""
- host = config.SEARXNG_DEFAULT_HOST
- if not host:
- return {"status": "unavailable", "error": "SEARXNG_DEFAULT_HOST not set"}
-
- healthz_url = urljoin(host if host.endswith("/") else f"{host}/", "healthz")
- t0 = time.perf_counter()
- try:
- async with httpx.AsyncClient(timeout=5.0, verify=False) as client:
- resp = await client.get(healthz_url)
- resp.raise_for_status()
- elapsed_ms = round((time.perf_counter() - t0) * 1000)
- return {
- "status": "healthy",
- "response_time_ms": elapsed_ms,
- "circuit_breaker": "open" if _circuit_is_open() else "closed",
- }
- except Exception as exc:
- elapsed_ms = round((time.perf_counter() - t0) * 1000)
- return {
- "status": "unhealthy",
- "error": str(exc),
- "response_time_ms": elapsed_ms,
- "circuit_breaker": "open" if _circuit_is_open() else "closed",
- }
-
-
-async def search(
- query: str,
- top_k: int = 20,
- *,
- engines: str | None = None,
- language: str | None = None,
- safesearch: int | None = None,
-) -> tuple[dict[str, Any], list[dict[str, Any]]]:
- """Execute a web search against the platform SearXNG instance.
-
- Returns the standard ``(result_object, documents)`` tuple expected by
- ``ConnectorService.search_searxng``.
- """
- host = config.SEARXNG_DEFAULT_HOST
- if not host:
- return dict(_EMPTY_RESULT), []
-
- if _circuit_is_open():
- logger.info("Web search skipped — circuit breaker is open")
- result = dict(_EMPTY_RESULT)
- result["error"] = "Web search temporarily unavailable (circuit open)"
- result["status"] = "degraded"
- return result, []
-
- ck = _cache_key(query, engines, language)
- cached = _cache_get(ck)
- if cached is not None:
- logger.debug("Web search cache HIT for query=%r", query[:60])
- return cached["result"], cached["documents"]
-
- params: dict[str, Any] = {
- "q": query,
- "format": "json",
- "limit": max(1, min(top_k, 50)),
- }
- if engines:
- params["engines"] = engines
- if language:
- params["language"] = language
- if safesearch is not None and 0 <= safesearch <= 2:
- params["safesearch"] = safesearch
-
- searx_endpoint = urljoin(host if host.endswith("/") else f"{host}/", "search")
- headers = {"Accept": "application/json"}
-
- data: dict[str, Any] | None = None
- last_error: Exception | None = None
-
- for attempt in range(2):
- try:
- async with httpx.AsyncClient(timeout=15.0, verify=False) as client:
- response = await client.get(
- searx_endpoint,
- params=params,
- headers=headers,
- )
- response.raise_for_status()
- data = response.json()
- break
- except (httpx.HTTPStatusError, httpx.TimeoutException) as exc:
- last_error = exc
- if attempt == 0 and (
- isinstance(exc, httpx.TimeoutException)
- or (
- isinstance(exc, httpx.HTTPStatusError)
- and exc.response.status_code >= 500
- )
- ):
- continue
- break
- except httpx.HTTPError as exc:
- last_error = exc
- break
- except ValueError as exc:
- last_error = exc
- break
-
- if data is None:
- _record_failure()
- logger.warning("Web search failed after retries: %s", last_error)
- return dict(_EMPTY_RESULT), []
-
- _record_success()
-
- searx_results = data.get("results", [])
- if not searx_results:
- return dict(_EMPTY_RESULT), []
-
- sources_list: list[dict[str, Any]] = []
- documents: list[dict[str, Any]] = []
-
- for idx, result in enumerate(searx_results):
- source_id = 200_000 + idx
- description = result.get("content") or result.get("snippet") or ""
-
- sources_list.append(
- {
- "id": source_id,
- "title": result.get("title", "Web Search Result"),
- "description": description,
- "url": result.get("url", ""),
- }
- )
-
- documents.append(
- {
- "chunk_id": source_id,
- "content": description or result.get("content", ""),
- "score": result.get("score", 0.0),
- "document": {
- "id": source_id,
- "title": result.get("title", "Web Search Result"),
- "document_type": "SEARXNG_API",
- "metadata": {
- "url": result.get("url", ""),
- "engines": result.get("engines", []),
- "category": result.get("category"),
- "source": "SEARXNG_API",
- },
- },
- }
- )
-
- result_object: dict[str, Any] = {
- "id": 11,
- "name": "Web Search",
- "type": "SEARXNG_API",
- "sources": sources_list,
- }
-
- _cache_set(ck, {"result": result_object, "documents": documents})
-
- return result_object, documents
diff --git a/surfsense_backend/app/session_events.py b/surfsense_backend/app/session_events.py
index 048df2b46..31a03e9f3 100644
--- a/surfsense_backend/app/session_events.py
+++ b/surfsense_backend/app/session_events.py
@@ -38,7 +38,7 @@ def _after_flush(session: Session, flush_context: object) -> None:
result = payload_if_entered_folder(
document_id=obj.id,
- search_space_id=obj.search_space_id,
+ workspace_id=obj.workspace_id,
new_folder_id=new_folder_id,
previous_folder_id=previous_folder_id,
folder_id_changed=True,
@@ -72,7 +72,7 @@ def _after_commit(session: Session) -> None:
default_bus.publish(
item["event_type"],
item["payload"],
- search_space_id=item["search_space_id"],
+ workspace_id=item["workspace_id"],
)
)
for item in pending
diff --git a/surfsense_backend/app/tasks/celery_tasks/__init__.py b/surfsense_backend/app/tasks/celery_tasks/__init__.py
index a1113884f..f2401d44e 100644
--- a/surfsense_backend/app/tasks/celery_tasks/__init__.py
+++ b/surfsense_backend/app/tasks/celery_tasks/__init__.py
@@ -94,6 +94,21 @@ def _dispose_shared_db_engine(loop: asyncio.AbstractEventLoop) -> None:
logger.warning("Shared DB engine dispose() failed", exc_info=True)
+def _dispose_shared_checkpointer_pool(loop: asyncio.AbstractEventLoop) -> None:
+ """Drop the shared checkpointer pool so the next task opens a fresh one.
+
+ The durable ``AsyncPostgresSaver`` pool binds asyncpg connections to the
+ loop that opened them; a later task on a fresh loop stalls on a stale
+ connection (``PoolTimeout``). Failure is logged, not raised.
+ """
+ try:
+ from app.agents.chat.runtime.checkpointer import close_checkpointer
+
+ loop.run_until_complete(close_checkpointer())
+ except Exception:
+ logger.warning("Shared checkpointer pool dispose() failed", exc_info=True)
+
+
T = TypeVar("T")
@@ -129,11 +144,13 @@ def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
# Defense-in-depth: prior task may have crashed before
# disposing. Idempotent — no-op if pool is already empty.
_dispose_shared_db_engine(loop)
+ _dispose_shared_checkpointer_pool(loop)
return loop.run_until_complete(coro_factory())
finally:
# Drop any connections this task opened so they don't leak
# into the next task's loop.
_dispose_shared_db_engine(loop)
+ _dispose_shared_checkpointer_pool(loop)
with contextlib.suppress(Exception):
loop.run_until_complete(loop.shutdown_asyncgens())
with contextlib.suppress(Exception):
diff --git a/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py b/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py
index 50f757473..ec078e031 100644
--- a/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py
@@ -64,7 +64,7 @@ def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> N
f"GREENLET ERROR in {task_name} for connector {connector_id}: {error_str}\n"
f"This error typically occurs when SQLAlchemy tries to lazy-load a relationship "
f"outside of an async context. Check for:\n"
- f"1. Accessing relationship attributes (e.g., document.chunks, connector.search_space) "
+ f"1. Accessing relationship attributes (e.g., document.chunks, connector.workspace) "
f"without using selectinload() or joinedload()\n"
f"2. Accessing model attributes after the session is closed\n"
f"3. Passing ORM objects between different async contexts\n"
@@ -81,7 +81,7 @@ def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> N
def index_notion_pages_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -90,7 +90,7 @@ def index_notion_pages_task(
try:
return run_async_celery_task(
lambda: _index_notion_pages(
- connector_id, search_space_id, user_id, start_date, end_date
+ connector_id, workspace_id, user_id, start_date, end_date
)
)
except Exception as e:
@@ -100,7 +100,7 @@ def index_notion_pages_task(
async def _index_notion_pages(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -112,7 +112,7 @@ async def _index_notion_pages(
async with get_celery_session_maker()() as session:
await run_notion_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
@@ -120,7 +120,7 @@ async def _index_notion_pages(
def index_github_repos_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -128,14 +128,14 @@ def index_github_repos_task(
"""Celery task to index GitHub repositories."""
return run_async_celery_task(
lambda: _index_github_repos(
- connector_id, search_space_id, user_id, start_date, end_date
+ connector_id, workspace_id, user_id, start_date, end_date
)
)
async def _index_github_repos(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -147,7 +147,7 @@ async def _index_github_repos(
async with get_celery_session_maker()() as session:
await run_github_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
@@ -155,7 +155,7 @@ async def _index_github_repos(
def index_confluence_pages_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -163,14 +163,14 @@ def index_confluence_pages_task(
"""Celery task to index Confluence pages."""
return run_async_celery_task(
lambda: _index_confluence_pages(
- connector_id, search_space_id, user_id, start_date, end_date
+ connector_id, workspace_id, user_id, start_date, end_date
)
)
async def _index_confluence_pages(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -182,7 +182,7 @@ async def _index_confluence_pages(
async with get_celery_session_maker()() as session:
await run_confluence_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
@@ -190,7 +190,7 @@ async def _index_confluence_pages(
def index_google_calendar_events_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -199,7 +199,7 @@ def index_google_calendar_events_task(
try:
return run_async_celery_task(
lambda: _index_google_calendar_events(
- connector_id, search_space_id, user_id, start_date, end_date
+ connector_id, workspace_id, user_id, start_date, end_date
)
)
except Exception as e:
@@ -209,7 +209,7 @@ def index_google_calendar_events_task(
async def _index_google_calendar_events(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -221,7 +221,7 @@ async def _index_google_calendar_events(
async with get_celery_session_maker()() as session:
await run_google_calendar_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
@@ -229,7 +229,7 @@ async def _index_google_calendar_events(
def index_google_gmail_messages_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -237,14 +237,14 @@ def index_google_gmail_messages_task(
"""Celery task to index Google Gmail messages."""
return run_async_celery_task(
lambda: _index_google_gmail_messages(
- connector_id, search_space_id, user_id, start_date, end_date
+ connector_id, workspace_id, user_id, start_date, end_date
)
)
async def _index_google_gmail_messages(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -256,7 +256,7 @@ async def _index_google_gmail_messages(
async with get_celery_session_maker()() as session:
await run_google_gmail_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
@@ -264,7 +264,7 @@ async def _index_google_gmail_messages(
def index_google_drive_files_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict, # Dictionary with 'folders', 'files', and 'indexing_options'
):
@@ -272,7 +272,7 @@ def index_google_drive_files_task(
return run_async_celery_task(
lambda: _index_google_drive_files(
connector_id,
- search_space_id,
+ workspace_id,
user_id,
items_dict,
)
@@ -281,7 +281,7 @@ def index_google_drive_files_task(
async def _index_google_drive_files(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict, # Dictionary with 'folders', 'files', and 'indexing_options'
):
@@ -294,7 +294,7 @@ async def _index_google_drive_files(
await run_google_drive_indexing(
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
items_dict,
)
@@ -304,7 +304,7 @@ async def _index_google_drive_files(
def index_onedrive_files_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict,
):
@@ -312,7 +312,7 @@ def index_onedrive_files_task(
return run_async_celery_task(
lambda: _index_onedrive_files(
connector_id,
- search_space_id,
+ workspace_id,
user_id,
items_dict,
)
@@ -321,7 +321,7 @@ def index_onedrive_files_task(
async def _index_onedrive_files(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict,
):
@@ -334,7 +334,7 @@ async def _index_onedrive_files(
await run_onedrive_indexing(
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
items_dict,
)
@@ -344,7 +344,7 @@ async def _index_onedrive_files(
def index_dropbox_files_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict,
):
@@ -352,7 +352,7 @@ def index_dropbox_files_task(
return run_async_celery_task(
lambda: _index_dropbox_files(
connector_id,
- search_space_id,
+ workspace_id,
user_id,
items_dict,
)
@@ -361,7 +361,7 @@ def index_dropbox_files_task(
async def _index_dropbox_files(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict,
):
@@ -374,7 +374,7 @@ async def _index_dropbox_files(
await run_dropbox_indexing(
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
items_dict,
)
@@ -384,7 +384,7 @@ async def _index_dropbox_files(
def index_elasticsearch_documents_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -392,14 +392,14 @@ def index_elasticsearch_documents_task(
"""Celery task to index Elasticsearch documents."""
return run_async_celery_task(
lambda: _index_elasticsearch_documents(
- connector_id, search_space_id, user_id, start_date, end_date
+ connector_id, workspace_id, user_id, start_date, end_date
)
)
async def _index_elasticsearch_documents(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -411,46 +411,7 @@ async def _index_elasticsearch_documents(
async with get_celery_session_maker()() as session:
await run_elasticsearch_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
- )
-
-
-@celery_app.task(name="index_crawled_urls", bind=True)
-def index_crawled_urls_task(
- self,
- connector_id: int,
- search_space_id: int,
- user_id: str,
- start_date: str,
- end_date: str,
-):
- """Celery task to index Web page Urls."""
- try:
- return run_async_celery_task(
- lambda: _index_crawled_urls(
- connector_id, search_space_id, user_id, start_date, end_date
- )
- )
- except Exception as e:
- _handle_greenlet_error(e, "index_crawled_urls", connector_id)
- raise
-
-
-async def _index_crawled_urls(
- connector_id: int,
- search_space_id: int,
- user_id: str,
- start_date: str,
- end_date: str,
-):
- """Index Web page Urls with new session."""
- from app.routes.search_source_connectors_routes import (
- run_web_page_indexing,
- )
-
- async with get_celery_session_maker()() as session:
- await run_web_page_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
@@ -458,7 +419,7 @@ async def _index_crawled_urls(
def index_bookstack_pages_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -466,14 +427,14 @@ def index_bookstack_pages_task(
"""Celery task to index BookStack pages."""
return run_async_celery_task(
lambda: _index_bookstack_pages(
- connector_id, search_space_id, user_id, start_date, end_date
+ connector_id, workspace_id, user_id, start_date, end_date
)
)
async def _index_bookstack_pages(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -485,7 +446,7 @@ async def _index_bookstack_pages(
async with get_celery_session_maker()() as session:
await run_bookstack_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
@@ -493,7 +454,7 @@ async def _index_bookstack_pages(
def index_composio_connector_task(
self,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None,
end_date: str | None,
@@ -501,14 +462,14 @@ def index_composio_connector_task(
"""Celery task to index Composio connector content (Google Drive, Gmail, Calendar via Composio)."""
return run_async_celery_task(
lambda: _index_composio_connector(
- connector_id, search_space_id, user_id, start_date, end_date
+ connector_id, workspace_id, user_id, start_date, end_date
)
)
async def _index_composio_connector(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None,
end_date: str | None,
@@ -521,5 +482,5 @@ async def _index_composio_connector(
async with get_celery_session_maker()() as session:
await run_composio_indexing(
- session, connector_id, search_space_id, user_id, start_date, end_date
+ session, connector_id, workspace_id, user_id, start_date, end_date
)
diff --git a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
index d36a7c05f..240e0b69c 100644
--- a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
@@ -41,7 +41,7 @@ async def _reindex_document(document_id: int, user_id: str):
logger.error(f"Document {document_id} not found")
return
- task_logger = TaskLoggingService(session, document.search_space_id)
+ task_logger = TaskLoggingService(session, document.workspace_id)
log_entry = await task_logger.log_task_start(
task_name="document_reindex",
diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
index 4d71d6c9a..e2ca5345e 100644
--- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
@@ -4,7 +4,6 @@ import asyncio
import contextlib
import logging
import os
-import time
from uuid import UUID
from app.celery_app import celery_app
@@ -19,7 +18,6 @@ from app.tasks.connector_indexers.local_folder_indexer import (
)
from app.tasks.document_processors import (
add_extension_received_document,
- add_youtube_video_document,
)
logger = logging.getLogger(__name__)
@@ -210,18 +208,16 @@ async def _delete_folder_documents(
retry_backoff_max=300,
max_retries=5,
)
-def delete_search_space_task(self, search_space_id: int):
- """Celery task to delete a search space and heavy child rows in batches."""
- return run_async_celery_task(
- lambda: _delete_search_space_background(search_space_id)
- )
+def delete_workspace_task(self, workspace_id: int):
+ """Celery task to delete a workspace and heavy child rows in batches."""
+ return run_async_celery_task(lambda: _delete_workspace_background(workspace_id))
-async def _delete_search_space_background(search_space_id: int) -> None:
- """Delete chunks/docs in batches first, then delete the search space."""
+async def _delete_workspace_background(workspace_id: int) -> None:
+ """Delete chunks/docs in batches first, then delete the workspace."""
from sqlalchemy import delete as sa_delete, select
- from app.db import Chunk, Document, SearchSpace
+ from app.db import Chunk, Document, Workspace
from app.file_storage.service import purge_document_blobs
async with get_celery_session_maker()() as session:
@@ -231,7 +227,7 @@ async def _delete_search_space_background(search_space_id: int) -> None:
chunk_ids_result = await session.execute(
select(Chunk.id)
.join(Document, Chunk.document_id == Document.id)
- .where(Document.search_space_id == search_space_id)
+ .where(Document.workspace_id == workspace_id)
.limit(batch_size)
)
chunk_ids = chunk_ids_result.scalars().all()
@@ -243,7 +239,7 @@ async def _delete_search_space_background(search_space_id: int) -> None:
while True:
doc_ids_result = await session.execute(
select(Document.id)
- .where(Document.search_space_id == search_space_id)
+ .where(Document.workspace_id == workspace_id)
.limit(batch_size)
)
doc_ids = doc_ids_result.scalars().all()
@@ -254,7 +250,7 @@ async def _delete_search_space_background(search_space_id: int) -> None:
await session.execute(sa_delete(Document).where(Document.id.in_(doc_ids)))
await session.commit()
- space = await session.get(SearchSpace, search_space_id)
+ space = await session.get(Workspace, workspace_id)
if space:
await session.delete(space)
await session.commit()
@@ -262,25 +258,25 @@ async def _delete_search_space_background(search_space_id: int) -> None:
@celery_app.task(name="process_extension_document", bind=True)
def process_extension_document_task(
- self, individual_document_dict, search_space_id: int, user_id: str
+ self, individual_document_dict, workspace_id: int, user_id: str
):
"""
Celery task to process extension document.
Args:
individual_document_dict: Document data as dictionary
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
"""
return run_async_celery_task(
lambda: _process_extension_document(
- individual_document_dict, search_space_id, user_id
+ individual_document_dict, workspace_id, user_id
)
)
async def _process_extension_document(
- individual_document_dict, search_space_id: int, user_id: str
+ individual_document_dict, workspace_id: int, user_id: str
):
"""Process extension document with new session."""
from pydantic import BaseModel, ConfigDict, Field
@@ -303,7 +299,7 @@ async def _process_extension_document(
individual_document = IndividualDocument(**individual_document_dict)
async with get_celery_session_maker()() as session:
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Truncate title for notification display
page_title = individual_document.metadata.VisitedWebPageTitle[:50]
@@ -317,7 +313,7 @@ async def _process_extension_document(
user_id=UUID(user_id),
document_type="EXTENSION",
document_name=page_title,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
)
@@ -343,7 +339,7 @@ async def _process_extension_document(
)
result = await add_extension_received_document(
- session, individual_document, search_space_id, user_id
+ session, individual_document, workspace_id, user_id
)
if result:
@@ -405,134 +401,9 @@ async def _process_extension_document(
raise
-@celery_app.task(name="process_youtube_video", bind=True)
-def process_youtube_video_task(self, url: str, search_space_id: int, user_id: str):
- """
- Celery task to process YouTube video.
-
- Args:
- url: YouTube video URL
- search_space_id: ID of the search space
- user_id: ID of the user
- """
- return run_async_celery_task(
- lambda: _process_youtube_video(url, search_space_id, user_id)
- )
-
-
-async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
- """Process YouTube video with new session."""
- async with get_celery_session_maker()() as session:
- task_logger = TaskLoggingService(session, search_space_id)
-
- # Extract video title from URL for notification (will be updated later)
- video_name = url.split("v=")[-1][:11] if "v=" in url else url
-
- # Create notification for document processing
- notification = (
- await NotificationService.document_processing.notify_processing_started(
- session=session,
- user_id=UUID(user_id),
- document_type="YOUTUBE_VIDEO",
- document_name=f"YouTube: {video_name}",
- search_space_id=search_space_id,
- )
- )
-
- # Start Redis heartbeat for stale task detection
- _start_heartbeat(notification.id)
- heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
-
- log_entry = await task_logger.log_task_start(
- task_name="process_youtube_video",
- source="document_processor",
- message=f"Starting YouTube video processing for: {url}",
- metadata={"document_type": "YOUTUBE_VIDEO", "url": url, "user_id": user_id},
- )
-
- try:
- # Update notification: parsing (fetching transcript)
- await NotificationService.document_processing.notify_processing_progress(
- session,
- notification,
- stage="parsing",
- stage_message="Fetching video transcript",
- )
-
- result = await add_youtube_video_document(
- session, url, search_space_id, user_id, notification=notification
- )
-
- if result:
- await task_logger.log_task_success(
- log_entry,
- f"Successfully processed YouTube video: {result.title}",
- {
- "document_id": result.id,
- "video_id": result.document_metadata.get("video_id"),
- "content_hash": result.content_hash,
- },
- )
-
- # Update notification on success
- await (
- NotificationService.document_processing.notify_processing_completed(
- session=session,
- notification=notification,
- document_id=result.id,
- chunks_count=None,
- )
- )
- else:
- await task_logger.log_task_success(
- log_entry,
- f"YouTube video document already exists (duplicate): {url}",
- {"duplicate_detected": True},
- )
-
- # Update notification for duplicate
- await (
- NotificationService.document_processing.notify_processing_completed(
- session=session,
- notification=notification,
- error_message="Video already exists (duplicate)",
- )
- )
- except Exception as e:
- await task_logger.log_task_failure(
- log_entry,
- f"Failed to process YouTube video: {url}",
- str(e),
- {"error_type": type(e).__name__},
- )
-
- # Update notification on failure - wrapped in try-except to ensure it doesn't fail silently
- try:
- # Refresh notification to ensure it's not stale after any rollback
- await session.refresh(notification)
- await (
- NotificationService.document_processing.notify_processing_completed(
- session=session,
- notification=notification,
- error_message=str(e)[:100],
- )
- )
- except Exception as notif_error:
- logger.error(
- f"Failed to update notification on failure: {notif_error!s}"
- )
-
- logger.error(f"Error processing YouTube video: {e!s}")
- raise
- finally:
- # Stop heartbeat — key deleted on success, expires on crash
- heartbeat_task.cancel()
- _stop_heartbeat(notification.id)
-
-
@celery_app.task(name="process_file_upload", bind=True)
def process_file_upload_task(
- self, file_path: str, filename: str, search_space_id: int, user_id: str
+ self, file_path: str, filename: str, workspace_id: int, user_id: str
):
"""
Celery task to process uploaded file.
@@ -540,14 +411,14 @@ def process_file_upload_task(
Args:
file_path: Path to the uploaded file
filename: Original filename
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
"""
import traceback
logger.info(
f"[process_file_upload] Task started - file: {filename}, "
- f"search_space_id: {search_space_id}, user_id: {user_id}"
+ f"workspace_id: {workspace_id}, user_id: {user_id}"
)
logger.info(f"[process_file_upload] File path: {file_path}")
@@ -567,7 +438,7 @@ def process_file_upload_task(
try:
run_async_celery_task(
- lambda: _process_file_upload(file_path, filename, search_space_id, user_id)
+ lambda: _process_file_upload(file_path, filename, workspace_id, user_id)
)
logger.info(
f"[process_file_upload] Task completed successfully for: {filename}"
@@ -581,7 +452,7 @@ def process_file_upload_task(
async def _process_file_upload(
- file_path: str, filename: str, search_space_id: int, user_id: str
+ file_path: str, filename: str, workspace_id: int, user_id: str
):
"""Process file upload with new session."""
from app.tasks.document_processors.file_processors import process_file_in_background
@@ -590,7 +461,7 @@ async def _process_file_upload(
async with get_celery_session_maker()() as session:
logger.info(f"[_process_file_upload] Database session created for: {filename}")
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Get file size for notification metadata
try:
@@ -611,7 +482,7 @@ async def _process_file_upload(
user_id=UUID(user_id),
document_type="FILE",
document_name=filename,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
file_size=file_size,
)
)
@@ -642,7 +513,7 @@ async def _process_file_upload(
result = await process_file_in_background(
file_path,
filename,
- search_space_id,
+ workspace_id,
user_id,
session,
task_logger,
@@ -709,7 +580,7 @@ async def _process_file_upload(
user_id=UUID(user_id),
document_name=filename,
document_type="FILE",
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
balance_micros=credit_error.balance_micros,
required_micros=credit_error.required_micros,
)
@@ -770,7 +641,7 @@ def process_file_upload_with_document_task(
document_id: int,
temp_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
use_vision_llm: bool = False,
processing_mode: str = "basic",
@@ -786,14 +657,14 @@ def process_file_upload_with_document_task(
document_id: ID of the pending document created in Phase 1
temp_path: Path to the uploaded file
filename: Original filename
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
"""
import traceback
logger.info(
f"[process_file_upload_with_document] Task started - document_id: {document_id}, "
- f"file: {filename}, search_space_id: {search_space_id}"
+ f"file: {filename}, workspace_id: {workspace_id}"
)
# Check if file exists and is accessible
@@ -817,7 +688,7 @@ def process_file_upload_with_document_task(
document_id,
temp_path,
filename,
- search_space_id,
+ workspace_id,
user_id,
use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
@@ -852,7 +723,7 @@ async def _process_file_with_document(
document_id: int,
temp_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
use_vision_llm: bool = False,
processing_mode: str = "basic",
@@ -879,7 +750,7 @@ async def _process_file_with_document(
logger.info(
f"[_process_file_with_document] Database session created for: {filename}"
)
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Get the document
document = await session.get(Document, document_id)
@@ -910,7 +781,7 @@ async def _process_file_with_document(
user_id=UUID(user_id),
document_type="FILE",
document_name=filename,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
file_size=file_size,
)
)
@@ -958,7 +829,7 @@ async def _process_file_with_document(
document=document,
file_path=temp_path,
filename=filename,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
session=session,
task_logger=task_logger,
@@ -1033,7 +904,7 @@ async def _process_file_with_document(
user_id=UUID(user_id),
document_name=filename,
document_type="FILE",
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
balance_micros=credit_error.balance_micros,
required_micros=credit_error.required_micros,
)
@@ -1092,7 +963,7 @@ def process_circleback_meeting_task(
meeting_name: str,
markdown_content: str,
metadata: dict,
- search_space_id: int,
+ workspace_id: int,
connector_id: int | None = None,
):
"""
@@ -1103,7 +974,7 @@ def process_circleback_meeting_task(
meeting_name: Name of the meeting
markdown_content: Meeting content formatted as markdown
metadata: Meeting metadata dictionary
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
connector_id: ID of the Circleback connector (for deletion support)
"""
return run_async_celery_task(
@@ -1112,7 +983,7 @@ def process_circleback_meeting_task(
meeting_name,
markdown_content,
metadata,
- search_space_id,
+ workspace_id,
connector_id,
)
)
@@ -1123,7 +994,7 @@ async def _process_circleback_meeting(
meeting_name: str,
markdown_content: str,
metadata: dict,
- search_space_id: int,
+ workspace_id: int,
connector_id: int | None = None,
):
"""Process Circleback meeting with new session."""
@@ -1132,7 +1003,7 @@ async def _process_circleback_meeting(
)
async with get_celery_session_maker()() as session:
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Get user_id from metadata if available
user_id = metadata.get("user_id")
@@ -1147,7 +1018,7 @@ async def _process_circleback_meeting(
user_id=UUID(user_id),
document_type="CIRCLEBACK",
document_name=f"Meeting: {meeting_name[:40]}",
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
)
@@ -1185,7 +1056,7 @@ async def _process_circleback_meeting(
meeting_name=meeting_name,
markdown_content=markdown_content,
metadata=metadata,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
)
@@ -1261,7 +1132,7 @@ async def _process_circleback_meeting(
@celery_app.task(name="index_local_folder", bind=True)
def index_local_folder_task(
self,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_path: str,
folder_name: str,
@@ -1273,7 +1144,7 @@ def index_local_folder_task(
"""Celery task to index a local folder. Config is passed directly — no connector row."""
return run_async_celery_task(
lambda: _index_local_folder_async(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
folder_path=folder_path,
folder_name=folder_name,
@@ -1286,7 +1157,7 @@ def index_local_folder_task(
async def _index_local_folder_async(
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_path: str,
folder_name: str,
@@ -1317,7 +1188,7 @@ async def _index_local_folder_async(
user_id=UUID(user_id),
document_type="LOCAL_FOLDER_FILE",
document_name=doc_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
)
notification_id = notification.id
@@ -1343,7 +1214,7 @@ async def _index_local_folder_async(
try:
_indexed, _skipped_or_failed, _rfid, err = await index_local_folder(
session=session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
folder_path=folder_path,
folder_name=folder_name,
@@ -1402,7 +1273,7 @@ async def _index_local_folder_async(
@celery_app.task(name="index_uploaded_folder_files", bind=True)
def index_uploaded_folder_files_task(
self,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_name: str,
root_folder_id: int,
@@ -1413,7 +1284,7 @@ def index_uploaded_folder_files_task(
"""Celery task to index files uploaded from the desktop app."""
return run_async_celery_task(
lambda: _index_uploaded_folder_files_async(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
folder_name=folder_name,
root_folder_id=root_folder_id,
@@ -1425,7 +1296,7 @@ def index_uploaded_folder_files_task(
async def _index_uploaded_folder_files_async(
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_name: str,
root_folder_id: int,
@@ -1449,7 +1320,7 @@ async def _index_uploaded_folder_files_async(
user_id=UUID(user_id),
document_type="LOCAL_FOLDER_FILE",
document_name=doc_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
)
notification_id = notification.id
@@ -1474,7 +1345,7 @@ async def _index_uploaded_folder_files_async(
try:
_indexed, _failed, err = await index_uploaded_files(
session=session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
folder_name=folder_name,
root_folder_id=root_folder_id,
@@ -1522,109 +1393,3 @@ async def _index_uploaded_folder_files_async(
heartbeat_task.cancel()
if notification_id is not None:
_stop_heartbeat(notification_id)
-
-
-# ===== AI File Sort tasks =====
-
-AI_SORT_LOCK_TTL_SECONDS = 600 # 10 minutes
-_ai_sort_redis = None
-
-
-def _get_ai_sort_redis():
- import redis
-
- global _ai_sort_redis
- if _ai_sort_redis is None:
- _ai_sort_redis = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
- return _ai_sort_redis
-
-
-def _ai_sort_lock_key(search_space_id: int) -> str:
- return f"ai_sort:search_space:{search_space_id}:lock"
-
-
-@celery_app.task(name="ai_sort_search_space", bind=True, max_retries=1)
-def ai_sort_search_space_task(self, search_space_id: int, user_id: str):
- """Full AI sort for all documents in a search space."""
- return run_async_celery_task(
- lambda: _ai_sort_search_space_async(search_space_id, user_id)
- )
-
-
-async def _ai_sort_search_space_async(search_space_id: int, user_id: str):
- r = _get_ai_sort_redis()
- lock_key = _ai_sort_lock_key(search_space_id)
-
- if not r.set(lock_key, "running", nx=True, ex=AI_SORT_LOCK_TTL_SECONDS):
- logger.info(
- "AI sort already running for search_space=%d, skipping",
- search_space_id,
- )
- return
-
- t_start = time.perf_counter()
- try:
- from app.services.ai_file_sort_service import ai_sort_all_documents
- from app.services.llm_service import get_agent_llm
-
- async with get_celery_session_maker()() as session:
- llm = await get_agent_llm(session, search_space_id, disable_streaming=True)
- if llm is None:
- logger.warning(
- "No LLM configured for search_space=%d, skipping AI sort",
- search_space_id,
- )
- return
-
- sorted_count, failed_count = await ai_sort_all_documents(
- session, search_space_id, llm
- )
- elapsed = time.perf_counter() - t_start
- logger.info(
- "AI sort search_space=%d done in %.1fs: sorted=%d failed=%d",
- search_space_id,
- elapsed,
- sorted_count,
- failed_count,
- )
- finally:
- r.delete(lock_key)
-
-
-@celery_app.task(
- name="ai_sort_document", bind=True, max_retries=2, default_retry_delay=10
-)
-def ai_sort_document_task(self, search_space_id: int, user_id: str, document_id: int):
- """Incremental AI sort for a single document after indexing."""
- return run_async_celery_task(
- lambda: _ai_sort_document_async(search_space_id, user_id, document_id)
- )
-
-
-async def _ai_sort_document_async(search_space_id: int, user_id: str, document_id: int):
- from app.db import Document
- from app.services.ai_file_sort_service import ai_sort_document
- from app.services.llm_service import get_agent_llm
-
- async with get_celery_session_maker()() as session:
- document = await session.get(Document, document_id)
- if document is None:
- logger.warning("Document %d not found, skipping AI sort", document_id)
- return
-
- llm = await get_agent_llm(session, search_space_id, disable_streaming=True)
- if llm is None:
- logger.warning(
- "No LLM for search_space=%d, skipping AI sort of doc=%d",
- search_space_id,
- document_id,
- )
- return
-
- await ai_sort_document(session, document, llm)
- await session.commit()
- logger.info(
- "AI sorted document=%d into search_space=%d",
- document_id,
- search_space_id,
- )
diff --git a/surfsense_backend/app/tasks/celery_tasks/schedule_checker_task.py b/surfsense_backend/app/tasks/celery_tasks/schedule_checker_task.py
index e88fb58b9..fc896005c 100644
--- a/surfsense_backend/app/tasks/celery_tasks/schedule_checker_task.py
+++ b/surfsense_backend/app/tasks/celery_tasks/schedule_checker_task.py
@@ -49,7 +49,6 @@ async def _check_and_trigger_schedules():
# Teams, Gmail, Calendar, Luma) use real-time tools instead.
from app.tasks.celery_tasks.connector_tasks import (
index_confluence_pages_task,
- index_crawled_urls_task,
index_elasticsearch_documents_task,
index_github_repos_task,
index_google_drive_files_task,
@@ -61,17 +60,24 @@ async def _check_and_trigger_schedules():
SearchSourceConnectorType.GITHUB_CONNECTOR: index_github_repos_task,
SearchSourceConnectorType.CONFLUENCE_CONNECTOR: index_confluence_pages_task,
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: index_elasticsearch_documents_task,
- SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: index_crawled_urls_task,
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: index_google_drive_files_task,
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: index_google_drive_files_task,
}
- from app.services.mcp_oauth.registry import LIVE_CONNECTOR_TYPES
+ from app.services.mcp_oauth.registry import (
+ DEPRECATED_INDEXING_CONNECTOR_TYPES,
+ LIVE_CONNECTOR_TYPES,
+ )
- # Disable obsolete periodic indexing for live connectors in one batch.
+ # Disable obsolete periodic indexing in one batch: live connectors
+ # (now real-time agent tools) and deprecated-indexing connectors
+ # (KB is files/notes/uploads only) no longer index on a schedule.
live_disabled = []
for connector in due_connectors:
- if connector.connector_type in LIVE_CONNECTOR_TYPES:
+ if (
+ connector.connector_type in LIVE_CONNECTOR_TYPES
+ or connector.connector_type in DEPRECATED_INDEXING_CONNECTOR_TYPES
+ ):
connector.periodic_indexing_enabled = False
connector.next_scheduled_at = None
live_disabled.append(connector)
@@ -79,7 +85,7 @@ async def _check_and_trigger_schedules():
await session.commit()
for c in live_disabled:
logger.info(
- "Disabled obsolete periodic indexing for live connector %s (%s)",
+ "Disabled obsolete periodic indexing for connector %s (%s)",
c.id,
c.connector_type.value,
)
@@ -142,7 +148,7 @@ async def _check_and_trigger_schedules():
if selected_folders or selected_files:
task.delay(
connector.id,
- connector.search_space_id,
+ connector.workspace_id,
str(connector.user_id),
{
"folders": selected_folders,
@@ -165,44 +171,10 @@ async def _check_and_trigger_schedules():
await session.commit()
continue
- # Special handling for Webcrawler - skip if no URLs configured
- elif (
- connector.connector_type
- == SearchSourceConnectorType.WEBCRAWLER_CONNECTOR
- ):
- from app.utils.webcrawler_utils import parse_webcrawler_urls
-
- connector_config = connector.config or {}
- urls = parse_webcrawler_urls(
- connector_config.get("INITIAL_URLS")
- )
-
- if urls:
- task.delay(
- connector.id,
- connector.search_space_id,
- str(connector.user_id),
- None, # start_date
- None, # end_date
- )
- else:
- # No URLs configured - skip indexing but still update next_scheduled_at
- logger.info(
- f"Webcrawler connector {connector.id} has no URLs configured, "
- "skipping periodic indexing (will check again at next scheduled time)"
- )
- from datetime import timedelta
-
- connector.next_scheduled_at = now + timedelta(
- minutes=connector.indexing_frequency_minutes
- )
- await session.commit()
- continue
-
else:
task.delay(
connector.id,
- connector.search_space_id,
+ connector.workspace_id,
str(connector.user_id),
None, # start_date - uses last_indexed_at
None, # end_date - uses now
diff --git a/surfsense_backend/app/tasks/celery_tasks/video_presentation_tasks.py b/surfsense_backend/app/tasks/celery_tasks/video_presentation_tasks.py
index c6ce0b350..4f934cc27 100644
--- a/surfsense_backend/app/tasks/celery_tasks/video_presentation_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/video_presentation_tasks.py
@@ -15,7 +15,7 @@ from app.db import VideoPresentation, VideoPresentationStatus
from app.services.billable_calls import (
BillingSettlementError,
QuotaInsufficientError,
- _resolve_agent_billing_for_search_space,
+ _resolve_agent_billing_for_workspace,
billable_call,
)
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
@@ -43,7 +43,7 @@ def generate_video_presentation_task(
self,
video_presentation_id: int,
source_content: str,
- search_space_id: int,
+ workspace_id: int,
user_prompt: str | None = None,
) -> dict:
"""
@@ -55,7 +55,7 @@ def generate_video_presentation_task(
lambda: _generate_video_presentation(
video_presentation_id,
source_content,
- search_space_id,
+ workspace_id,
user_prompt,
)
)
@@ -96,7 +96,7 @@ async def _mark_video_presentation_failed(video_presentation_id: int) -> None:
async def _generate_video_presentation(
video_presentation_id: int,
source_content: str,
- search_space_id: int,
+ workspace_id: int,
user_prompt: str | None = None,
) -> dict:
"""Generate video presentation and update existing record."""
@@ -120,17 +120,16 @@ async def _generate_video_presentation(
owner_user_id,
billing_tier,
base_model,
- ) = await _resolve_agent_billing_for_search_space(
+ ) = await _resolve_agent_billing_for_workspace(
session,
- search_space_id,
+ workspace_id,
thread_id=video_pres.thread_id,
)
except ValueError as resolve_err:
logger.error(
- "VideoPresentation %s: cannot resolve billing for "
- "search_space=%s: %s",
+ "VideoPresentation %s: cannot resolve billing for workspace=%s: %s",
video_pres.id,
- search_space_id,
+ workspace_id,
resolve_err,
)
video_pres.status = VideoPresentationStatus.FAILED
@@ -144,7 +143,7 @@ async def _generate_video_presentation(
graph_config = {
"configurable": {
"video_title": video_pres.title,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"user_prompt": user_prompt,
}
}
@@ -157,7 +156,7 @@ async def _generate_video_presentation(
try:
async with billable_call(
user_id=owner_user_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
billing_tier=billing_tier,
base_model=base_model,
quota_reserve_micros_override=app_config.QUOTA_DEFAULT_VIDEO_PRESENTATION_RESERVE_MICROS,
diff --git a/surfsense_backend/app/tasks/chat/persistence.py b/surfsense_backend/app/tasks/chat/persistence.py
index 8840ec995..f78849607 100644
--- a/surfsense_backend/app/tasks/chat/persistence.py
+++ b/surfsense_backend/app/tasks/chat/persistence.py
@@ -412,7 +412,7 @@ async def finalize_assistant_turn(
*,
message_id: int,
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str | None,
turn_id: str,
content: list[dict[str, Any]],
@@ -517,7 +517,7 @@ async def finalize_assistant_turn(
call_details={"calls": accumulator.serialized_calls()},
thread_id=chat_id,
message_id=message_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_uuid,
)
.on_conflict_do_nothing(
diff --git a/surfsense_backend/app/tasks/chat/streaming/agent/builder.py b/surfsense_backend/app/tasks/chat/streaming/agent/builder.py
index 9d7d1b0c5..a199d860a 100644
--- a/surfsense_backend/app/tasks/chat/streaming/agent/builder.py
+++ b/surfsense_backend/app/tasks/chat/streaming/agent/builder.py
@@ -22,14 +22,13 @@ async def build_main_agent_for_thread(
agent_factory: Any,
*,
llm: Any,
- search_space_id: int,
+ workspace_id: int,
db_session: Any,
connector_service: ConnectorService,
checkpointer: Any,
user_id: str | None,
thread_id: int | None,
agent_config: AgentConfig | None,
- firecrawl_api_key: str | None,
thread_visibility: ChatVisibility | None,
filesystem_selection: FilesystemSelection | None,
disabled_tools: list[str] | None = None,
@@ -38,14 +37,13 @@ async def build_main_agent_for_thread(
) -> Any:
return await agent_factory(
llm=llm,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
db_session=db_session,
connector_service=connector_service,
checkpointer=checkpointer,
user_id=user_id,
thread_id=thread_id,
agent_config=agent_config,
- firecrawl_api_key=firecrawl_api_key,
thread_visibility=thread_visibility,
filesystem_selection=filesystem_selection,
disabled_tools=disabled_tools,
diff --git a/surfsense_backend/app/tasks/chat/streaming/agent/event_loop.py b/surfsense_backend/app/tasks/chat/streaming/agent/event_loop.py
index 5ffe46280..ed3a59893 100644
--- a/surfsense_backend/app/tasks/chat/streaming/agent/event_loop.py
+++ b/surfsense_backend/app/tasks/chat/streaming/agent/event_loop.py
@@ -46,7 +46,7 @@ async def stream_agent_events(
initial_step_title: str = "",
initial_step_items: list[str] | None = None,
*,
- fallback_commit_search_space_id: int | None = None,
+ fallback_commit_workspace_id: int | None = None,
fallback_commit_created_by_id: str | None = None,
fallback_commit_filesystem_mode: FilesystemMode = FilesystemMode.CLOUD,
fallback_commit_thread_id: int | None = None,
@@ -92,7 +92,7 @@ async def stream_agent_events(
# so reducers fire as if the after_agent hook produced it.
if (
fallback_commit_filesystem_mode == FilesystemMode.CLOUD
- and fallback_commit_search_space_id is not None
+ and fallback_commit_workspace_id is not None
and (
(state_values.get("dirty_paths") or [])
or (state_values.get("staged_dirs") or [])
@@ -104,7 +104,7 @@ async def stream_agent_events(
try:
delta = await commit_staged_filesystem_state(
state_values,
- search_space_id=fallback_commit_search_space_id,
+ workspace_id=fallback_commit_workspace_id,
created_by_id=fallback_commit_created_by_id,
filesystem_mode=fallback_commit_filesystem_mode,
thread_id=fallback_commit_thread_id,
diff --git a/surfsense_backend/app/tasks/chat/streaming/errors/classifier.py b/surfsense_backend/app/tasks/chat/streaming/errors/classifier.py
index 3fc5918ee..8aa703489 100644
--- a/surfsense_backend/app/tasks/chat/streaming/errors/classifier.py
+++ b/surfsense_backend/app/tasks/chat/streaming/errors/classifier.py
@@ -37,7 +37,7 @@ def log_chat_stream_error(
is_expected: bool,
request_id: str | None,
thread_id: int | None,
- search_space_id: int | None,
+ workspace_id: int | None,
user_id: str | None,
message: str,
extra: dict[str, Any] | None = None,
@@ -51,7 +51,7 @@ def log_chat_stream_error(
"is_expected": is_expected,
"request_id": request_id or "unknown",
"thread_id": thread_id,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"user_id": user_id,
"message": message,
}
diff --git a/surfsense_backend/app/tasks/chat/streaming/errors/emitter.py b/surfsense_backend/app/tasks/chat/streaming/errors/emitter.py
index 95806ab87..c2b874ba0 100644
--- a/surfsense_backend/app/tasks/chat/streaming/errors/emitter.py
+++ b/surfsense_backend/app/tasks/chat/streaming/errors/emitter.py
@@ -13,7 +13,7 @@ def emit_stream_terminal_error(
flow: Literal["new", "resume", "regenerate"],
request_id: str | None,
thread_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str | None,
message: str,
error_kind: str = "server_error",
@@ -30,7 +30,7 @@ def emit_stream_terminal_error(
is_expected=is_expected,
request_id=request_id,
thread_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
message=message,
extra=extra,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/auto_pin.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/auto_pin.py
index dbb8ee2e4..1e5de2189 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/auto_pin.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/auto_pin.py
@@ -1,7 +1,7 @@
"""Resolve the auto-pin for the *initial* turn config.
Auto-pin (``selected_llm_config_id=0``) picks the best eligible LLM config for
-this thread / search space / user, optionally filtered to vision-capable
+this thread / workspace / user, optionally filtered to vision-capable
configs when the turn carries images.
Errors classified here:
@@ -45,7 +45,7 @@ async def resolve_initial_auto_pin(
session: AsyncSession,
*,
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str | None,
selected_llm_config_id: int,
requires_image_input: bool,
@@ -62,7 +62,7 @@ async def resolve_initial_auto_pin(
pinned = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
selected_llm_config_id=selected_llm_config_id,
requires_image_input=requires_image_input,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
index 7be84c992..18b3b8152 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
@@ -64,7 +64,7 @@ async def build_new_chat_input_state(
session: AsyncSession,
*,
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
user_query: str,
user_image_data_urls: list[str] | None,
mentioned_document_ids: list[int] | None,
@@ -110,7 +110,7 @@ async def build_new_chat_input_state(
agent_user_query, accepted_folder_ids = await _resolve_mentions_for_query(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_query=user_query,
filesystem_mode=filesystem_mode,
mentioned_document_ids=mentioned_document_ids,
@@ -122,7 +122,7 @@ async def build_new_chat_input_state(
# filesystem mode (unlike the doc/folder mention substitution above).
referenced_chats = await resolve_referenced_chats(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
requesting_user_id=requesting_user_id,
current_chat_id=chat_id,
mentioned_thread_ids=mentioned_thread_ids,
@@ -146,7 +146,7 @@ async def build_new_chat_input_state(
input_state = {
"messages": langchain_messages,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"request_id": request_id or "unknown",
"turn_id": turn_id,
}
@@ -160,7 +160,7 @@ async def build_new_chat_input_state(
async def _resolve_mentions_for_query(
session: AsyncSession,
*,
- search_space_id: int,
+ workspace_id: int,
user_query: str,
filesystem_mode: str,
mentioned_document_ids: list[int] | None,
@@ -206,7 +206,7 @@ async def _resolve_mentions_for_query(
resolved = await resolve_mentions(
session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
mentioned_documents=chip_objs,
mentioned_document_ids=mentioned_document_ids,
mentioned_folder_ids=mentioned_folder_ids,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
index 0e49af249..4451b4b37 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
@@ -83,7 +83,7 @@ from app.tasks.chat.streaming.flows.shared.first_frames import (
from app.tasks.chat.streaming.flows.shared.llm_bundle import load_llm_bundle
from app.tasks.chat.streaming.flows.shared.pre_stream_setup import (
get_chat_checkpointer,
- setup_connector_and_firecrawl,
+ setup_connector_service,
)
from app.tasks.chat.streaming.flows.shared.premium_quota import (
CreditReservation,
@@ -120,7 +120,7 @@ _background_tasks: set[asyncio.Task] = set()
async def stream_new_chat(
user_query: str,
- search_space_id: int,
+ workspace_id: int,
chat_id: int,
user_id: str | None = None,
llm_config_id: int = -1,
@@ -165,7 +165,7 @@ async def stream_new_chat(
chat_error_category: str | None = None
chat_span_cm, chat_span = open_chat_request_span(
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
flow=flow,
request_id=request_id,
turn_id=stream_result.turn_id,
@@ -194,7 +194,7 @@ async def stream_new_chat(
flow=flow,
request_id=request_id,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -217,7 +217,7 @@ async def stream_new_chat(
pin_result = await resolve_initial_auto_pin(
session,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
selected_llm_config_id=llm_config_id,
requires_image_input=requires_image_input,
@@ -233,7 +233,7 @@ async def stream_new_chat(
llm_config_id = pin_result.llm_config_id # type: ignore[assignment]
llm, agent_config, llm_load_error = await load_llm_bundle(
- session, config_id=llm_config_id, search_space_id=search_space_id
+ session, config_id=llm_config_id, workspace_id=workspace_id
)
if llm_load_error:
yield emit_stream_error(
@@ -273,7 +273,7 @@ async def stream_new_chat(
pin_fallback = await resolve_initial_auto_pin(
session,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
selected_llm_config_id=0,
requires_image_input=requires_image_input,
@@ -300,7 +300,7 @@ async def stream_new_chat(
llm, agent_config, llm_load_error = await load_llm_bundle(
session,
config_id=llm_config_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
if llm_load_error:
yield emit_stream_error(
@@ -325,7 +325,7 @@ async def stream_new_chat(
is_expected=True,
request_id=request_id,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
message=(
"Premium quota exhausted on pinned model; "
@@ -376,11 +376,11 @@ async def stream_new_chat(
)
_t0 = time.perf_counter()
- connector_service, firecrawl_api_key = await setup_connector_and_firecrawl(
- session, search_space_id=search_space_id
+ connector_service = await setup_connector_service(
+ session, workspace_id=workspace_id
)
_perf_log.info(
- "[stream_new_chat] Connector service + firecrawl key in %.3fs",
+ "[stream_new_chat] Connector service in %.3fs",
time.perf_counter() - _t0,
)
@@ -403,14 +403,13 @@ async def stream_new_chat(
agent = await build_main_agent_for_thread(
agent_factory,
llm=llm,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
db_session=session,
connector_service=connector_service,
checkpointer=checkpointer,
user_id=user_id,
thread_id=chat_id,
agent_config=agent_config,
- firecrawl_api_key=firecrawl_api_key,
thread_visibility=visibility,
filesystem_selection=filesystem_selection,
disabled_tools=disabled_tools,
@@ -427,7 +426,7 @@ async def stream_new_chat(
assembled = await build_new_chat_input_state(
session,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_query=user_query,
user_image_data_urls=user_image_data_urls,
mentioned_document_ids=mentioned_document_ids,
@@ -592,7 +591,7 @@ async def stream_new_chat(
title_emitted = False
runtime_context = build_new_chat_runtime_context(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
mentioned_document_ids=mentioned_document_ids,
accepted_folder_ids=accepted_folder_ids,
mentioned_folder_ids=mentioned_folder_ids,
@@ -632,13 +631,13 @@ async def stream_new_chat(
llm_config_id = await reroute_to_next_auto_pin(
session,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
current_llm_config_id=llm_config_id,
requires_image_input=requires_image_input,
)
new_llm, new_agent_config, llm_load_err = await load_llm_bundle(
- session, config_id=llm_config_id, search_space_id=search_space_id
+ session, config_id=llm_config_id, workspace_id=workspace_id
)
if llm_load_err:
# Re-raise the original so the terminal-error path classifies
@@ -658,14 +657,13 @@ async def stream_new_chat(
new_agent = await build_main_agent_for_thread(
agent_factory,
llm=llm,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
db_session=session,
connector_service=connector_service,
checkpointer=checkpointer,
user_id=user_id,
thread_id=chat_id,
agent_config=agent_config,
- firecrawl_api_key=firecrawl_api_key,
thread_visibility=visibility,
filesystem_selection=filesystem_selection,
disabled_tools=disabled_tools,
@@ -683,7 +681,7 @@ async def stream_new_chat(
flow=flow,
request_id=request_id,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
previous_config_id=previous_config_id,
new_config_id=llm_config_id,
@@ -700,7 +698,7 @@ async def stream_new_chat(
initial_step_id=initial_step_id,
initial_step_title=initial_step_title,
initial_step_items=initial_step_items,
- fallback_commit_search_space_id=search_space_id,
+ fallback_commit_workspace_id=workspace_id,
fallback_commit_created_by_id=user_id,
fallback_commit_filesystem_mode=(
filesystem_selection.mode
@@ -793,7 +791,7 @@ async def stream_new_chat(
streaming_service=streaming_service,
request_id=request_id,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
chat_span=chat_span,
)
@@ -826,7 +824,7 @@ async def stream_new_chat(
await finalize_assistant_message(
stream_result=stream_result,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
accumulator=accumulator,
log_prefix="stream_new_chat",
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/runtime_context.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/runtime_context.py
index 5ef2b8ad1..2b6c2b545 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/runtime_context.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/runtime_context.py
@@ -13,7 +13,7 @@ from app.agents.chat.shared.context import SurfSenseContextSchema
def build_new_chat_runtime_context(
*,
- search_space_id: int,
+ workspace_id: int,
mentioned_document_ids: list[int] | None,
accepted_folder_ids: list[int],
mentioned_folder_ids: list[int] | None,
@@ -34,7 +34,7 @@ def build_new_chat_runtime_context(
middleware reads them yet.
"""
return SurfSenseContextSchema(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
mentioned_document_ids=list(mentioned_document_ids or []),
mentioned_folder_ids=list(accepted_folder_ids or mentioned_folder_ids or []),
mentioned_connector_ids=list(mentioned_connector_ids or []),
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
index 33fcee3da..8b7956f4c 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
@@ -62,7 +62,7 @@ from app.tasks.chat.streaming.flows.shared.first_frames import (
from app.tasks.chat.streaming.flows.shared.llm_bundle import load_llm_bundle
from app.tasks.chat.streaming.flows.shared.pre_stream_setup import (
get_chat_checkpointer,
- setup_connector_and_firecrawl,
+ setup_connector_service,
)
from app.tasks.chat.streaming.flows.shared.premium_quota import (
CreditReservation,
@@ -95,7 +95,7 @@ _perf_log = get_perf_logger()
async def stream_resume_chat(
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
decisions: list[dict],
user_id: str | None = None,
llm_config_id: int = -1,
@@ -127,7 +127,7 @@ async def stream_resume_chat(
chat_error_category: str | None = None
chat_span_cm, chat_span = open_chat_request_span(
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
flow="resume",
request_id=request_id,
turn_id=stream_result.turn_id,
@@ -155,7 +155,7 @@ async def stream_resume_chat(
flow="resume",
request_id=request_id,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -177,7 +177,7 @@ async def stream_resume_chat(
pinned = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
selected_llm_config_id=llm_config_id,
)
@@ -200,7 +200,7 @@ async def stream_resume_chat(
return
llm, agent_config, llm_load_error = await load_llm_bundle(
- session, config_id=llm_config_id, search_space_id=search_space_id
+ session, config_id=llm_config_id, workspace_id=workspace_id
)
if llm_load_error:
yield emit_stream_error(
@@ -226,7 +226,7 @@ async def stream_resume_chat(
pinned_fb = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
selected_llm_config_id=0,
force_repin_free=True,
@@ -250,7 +250,7 @@ async def stream_resume_chat(
llm, agent_config, llm_load_error = await load_llm_bundle(
session,
config_id=llm_config_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
if llm_load_error:
yield emit_stream_error(
@@ -273,7 +273,7 @@ async def stream_resume_chat(
is_expected=True,
request_id=request_id,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
message=(
"Premium quota exhausted on pinned model; "
@@ -314,11 +314,11 @@ async def stream_resume_chat(
# --- Pre-stream setup ---
_t0 = time.perf_counter()
- connector_service, firecrawl_api_key = await setup_connector_and_firecrawl(
- session, search_space_id=search_space_id
+ connector_service = await setup_connector_service(
+ session, workspace_id=workspace_id
)
_perf_log.info(
- "[stream_resume] Connector service + firecrawl key in %.3fs",
+ "[stream_resume] Connector service in %.3fs",
time.perf_counter() - _t0,
)
@@ -337,14 +337,13 @@ async def stream_resume_chat(
agent = await build_main_agent_for_thread(
agent_factory,
llm=llm,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
db_session=session,
connector_service=connector_service,
checkpointer=checkpointer,
user_id=user_id,
thread_id=chat_id,
agent_config=agent_config,
- firecrawl_api_key=firecrawl_api_key,
thread_visibility=visibility,
filesystem_selection=filesystem_selection,
disabled_tools=disabled_tools,
@@ -423,7 +422,7 @@ async def stream_resume_chat(
stream_result.content_builder = AssistantContentBuilder()
runtime_context = build_resume_chat_runtime_context(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
request_id=request_id,
turn_id=stream_result.turn_id,
)
@@ -456,13 +455,13 @@ async def stream_resume_chat(
llm_config_id = await reroute_to_next_auto_pin(
session,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
current_llm_config_id=llm_config_id,
requires_image_input=False,
)
new_llm, new_agent_config, llm_load_err = await load_llm_bundle(
- session, config_id=llm_config_id, search_space_id=search_space_id
+ session, config_id=llm_config_id, workspace_id=workspace_id
)
if llm_load_err:
return None
@@ -473,14 +472,13 @@ async def stream_resume_chat(
new_agent = await build_main_agent_for_thread(
agent_factory,
llm=llm,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
db_session=session,
connector_service=connector_service,
checkpointer=checkpointer,
user_id=user_id,
thread_id=chat_id,
agent_config=agent_config,
- firecrawl_api_key=firecrawl_api_key,
thread_visibility=visibility,
filesystem_selection=filesystem_selection,
disabled_tools=disabled_tools,
@@ -497,7 +495,7 @@ async def stream_resume_chat(
flow="resume",
request_id=request_id,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
previous_config_id=previous_config_id,
new_config_id=llm_config_id,
@@ -511,7 +509,7 @@ async def stream_resume_chat(
input_data=Command(resume=routing.lg_resume_map),
stream_result=stream_result,
step_prefix=resume_step_prefix(stream_result.turn_id),
- fallback_commit_search_space_id=search_space_id,
+ fallback_commit_workspace_id=workspace_id,
fallback_commit_created_by_id=user_id,
fallback_commit_filesystem_mode=(
filesystem_selection.mode
@@ -572,7 +570,7 @@ async def stream_resume_chat(
streaming_service=streaming_service,
request_id=request_id,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
chat_span=chat_span,
)
@@ -595,7 +593,7 @@ async def stream_resume_chat(
await finalize_assistant_message(
stream_result=stream_result,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
accumulator=accumulator,
log_prefix="stream_resume",
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/runtime_context.py b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/runtime_context.py
index 54f0dfba0..a1be8c36b 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/runtime_context.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/runtime_context.py
@@ -12,12 +12,12 @@ from app.agents.chat.shared.context import SurfSenseContextSchema
def build_resume_chat_runtime_context(
*,
- search_space_id: int,
+ workspace_id: int,
request_id: str | None,
turn_id: str,
) -> SurfSenseContextSchema:
return SurfSenseContextSchema(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
request_id=request_id,
turn_id=turn_id,
)
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/assistant_finalize.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/assistant_finalize.py
index c59c2dcda..04cba6f5f 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/assistant_finalize.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/assistant_finalize.py
@@ -54,7 +54,7 @@ def _resolve_citations(
) -> list[dict[str, Any]]:
"""Rewrite ``[n]`` -> ``[citation:]`` in each text part before persisting.
- No-op when the turn registered no citable sources; ``web_search``'s existing
+ No-op when the turn registered no citable sources; any pre-existing
``[citation:url]`` markers pass through untouched (the regex matches bare ``[n]``).
"""
registry = _as_registry(raw_registry)
@@ -70,7 +70,7 @@ async def finalize_assistant_message(
*,
stream_result: StreamResult | None,
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str | None,
accumulator: TokenAccumulator,
log_prefix: str,
@@ -140,7 +140,7 @@ async def finalize_assistant_message(
await finalize_assistant_turn(
message_id=stream_result.assistant_message_id,
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
turn_id=stream_result.turn_id,
content=content_payload,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/llm_bundle.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/llm_bundle.py
index 6f905e8f4..5d1a4bc83 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/llm_bundle.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/llm_bundle.py
@@ -21,7 +21,7 @@ from app.agents.chat.runtime.llm_config import (
SanitizedChatLiteLLM,
)
from app.config import config
-from app.db import Model, SearchSpace
+from app.db import Model, Workspace
from app.services.model_capabilities import has_capability
from app.services.model_resolver import to_litellm
from app.services.token_tracking_service import register_model_usage_metadata
@@ -55,11 +55,9 @@ def _agent_config_from_resolved(
)
-async def _load_search_space(
- session: AsyncSession, search_space_id: int
-) -> SearchSpace | None:
+async def _load_workspace(session: AsyncSession, workspace_id: int) -> Workspace | None:
result = await session.execute(
- select(SearchSpace).where(SearchSpace.id == search_space_id)
+ select(Workspace).where(Workspace.id == workspace_id)
)
return result.scalars().first()
@@ -68,7 +66,7 @@ async def _load_db_model(
session: AsyncSession,
*,
model_id: int,
- search_space: SearchSpace,
+ workspace: Workspace,
) -> Model | None:
result = await session.execute(
select(Model)
@@ -79,9 +77,9 @@ async def _load_db_model(
if not model or not model.connection or not model.connection.enabled:
return None
conn = model.connection
- if conn.search_space_id is not None and conn.search_space_id != search_space.id:
+ if conn.workspace_id is not None and conn.workspace_id != workspace.id:
return None
- if conn.user_id is not None and conn.user_id != search_space.user_id:
+ if conn.user_id is not None and conn.user_id != workspace.user_id:
return None
return model
@@ -90,17 +88,17 @@ async def load_llm_bundle(
session: AsyncSession,
*,
config_id: int,
- search_space_id: int,
+ workspace_id: int,
) -> tuple[Any, AgentConfig | None, str | None]:
- search_space = await _load_search_space(session, search_space_id)
- if not search_space:
- return None, None, f"Search space {search_space_id} not found"
+ workspace = await _load_workspace(session, workspace_id)
+ if not workspace:
+ return None, None, f"Workspace {workspace_id} not found"
if config_id > 0:
model = await _load_db_model(
session,
model_id=config_id,
- search_space=search_space,
+ workspace=workspace,
)
if not model or not has_capability(model, "chat"):
return (
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/pre_stream_setup.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/pre_stream_setup.py
index f717cb325..4cfb4fd60 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/pre_stream_setup.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/pre_stream_setup.py
@@ -1,33 +1,20 @@
-"""Pre-stream setup: connector service, firecrawl key, checkpointer."""
+"""Pre-stream setup: connector service, checkpointer."""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.chat.runtime.checkpointer import get_checkpointer
-from app.db import SearchSourceConnectorType
from app.services.connector_service import ConnectorService
-async def setup_connector_and_firecrawl(
+async def setup_connector_service(
session: AsyncSession,
*,
- search_space_id: int,
-) -> tuple[ConnectorService, str | None]:
- """Build the per-turn connector service and pull the firecrawl API key.
-
- Returns ``(connector_service, firecrawl_api_key)``. ``firecrawl_api_key`` is
- ``None`` when no web-crawler connector is configured (the agent simply
- skips firecrawl-backed tools in that case).
- """
- connector_service = ConnectorService(session, search_space_id=search_space_id)
- firecrawl_api_key: str | None = None
- webcrawler_connector = await connector_service.get_connector_by_type(
- SearchSourceConnectorType.WEBCRAWLER_CONNECTOR, search_space_id
- )
- if webcrawler_connector and webcrawler_connector.config:
- firecrawl_api_key = webcrawler_connector.config.get("FIRECRAWL_API_KEY")
- return connector_service, firecrawl_api_key
+ workspace_id: int,
+) -> ConnectorService:
+ """Build the per-turn connector service for the workspace."""
+ return ConnectorService(session, workspace_id=workspace_id)
async def get_chat_checkpointer():
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/rate_limit_recovery.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/rate_limit_recovery.py
index 29018fe07..4a0a15f54 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/rate_limit_recovery.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/rate_limit_recovery.py
@@ -62,7 +62,7 @@ async def reroute_to_next_auto_pin(
session: AsyncSession,
*,
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str | None,
current_llm_config_id: int,
requires_image_input: bool,
@@ -79,7 +79,7 @@ async def reroute_to_next_auto_pin(
pinned = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
selected_llm_config_id=0,
exclude_config_ids={current_llm_config_id},
@@ -93,7 +93,7 @@ def log_rate_limit_recovered(
flow: Literal["new", "regenerate", "resume"],
request_id: str | None,
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str | None,
previous_config_id: int,
new_config_id: int,
@@ -115,7 +115,7 @@ def log_rate_limit_recovered(
is_expected=True,
request_id=request_id,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
message=(
"Auto-pinned model hit runtime rate limit; switched to "
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/span.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/span.py
index 74b9682ed..18215c8f2 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/span.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/span.py
@@ -12,7 +12,7 @@ from app.observability import metrics as ot_metrics, otel as ot
def open_chat_request_span(
*,
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
flow: Literal["new", "regenerate", "resume"],
request_id: str | None,
turn_id: str,
@@ -23,7 +23,7 @@ def open_chat_request_span(
"""Open the per-request span; returns ``(span_cm, span)`` for finally-close."""
span_cm = ot.chat_request_span(
chat_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
flow=flow,
request_id=request_id,
turn_id=turn_id,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/stream_loop.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/stream_loop.py
index f455a8ffd..ff830fe1b 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/stream_loop.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/stream_loop.py
@@ -37,7 +37,7 @@ async def run_stream_loop(
initial_step_id: str | None = None,
initial_step_title: str = "",
initial_step_items: list[str] | None = None,
- fallback_commit_search_space_id: int | None,
+ fallback_commit_workspace_id: int | None,
fallback_commit_created_by_id: str | None,
fallback_commit_filesystem_mode: FilesystemMode,
fallback_commit_thread_id: int | None,
@@ -64,7 +64,7 @@ async def run_stream_loop(
initial_step_id=initial_step_id,
initial_step_title=initial_step_title,
initial_step_items=initial_step_items,
- fallback_commit_search_space_id=fallback_commit_search_space_id,
+ fallback_commit_workspace_id=fallback_commit_workspace_id,
fallback_commit_created_by_id=fallback_commit_created_by_id,
fallback_commit_filesystem_mode=fallback_commit_filesystem_mode,
fallback_commit_thread_id=fallback_commit_thread_id,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/terminal_error.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/terminal_error.py
index 126149cc1..1e927d5d5 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/terminal_error.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/terminal_error.py
@@ -34,7 +34,7 @@ def handle_terminal_exception(
streaming_service: VercelStreamingService,
request_id: str | None,
chat_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str | None,
chat_span: Any,
) -> tuple[Iterator[str], dict[str, Any]]:
@@ -87,7 +87,7 @@ def handle_terminal_exception(
flow=flow,
request_id=request_id,
thread_id=chat_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
message=user_message,
error_kind=error_kind,
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/custom_event_dispatch.py b/surfsense_backend/app/tasks/chat/streaming/handlers/custom_event_dispatch.py
index 69f4b8a24..35d96f1f8 100644
--- a/surfsense_backend/app/tasks/chat/streaming/handlers/custom_event_dispatch.py
+++ b/surfsense_backend/app/tasks/chat/streaming/handlers/custom_event_dispatch.py
@@ -10,6 +10,7 @@ from app.tasks.chat.streaming.handlers.custom_events import (
handle_action_log_updated,
handle_document_created,
handle_report_progress,
+ handle_scraper_progress,
)
from app.tasks.chat.streaming.relay.state import AgentEventRelayState
@@ -39,6 +40,20 @@ def iter_custom_event_frames(
yield frame
return
+ if name == "scraper_progress":
+ frame, state.last_active_step_items = handle_scraper_progress(
+ data,
+ last_active_step_id=state.last_active_step_id,
+ last_active_step_title=state.last_active_step_title,
+ last_active_step_items=state.last_active_step_items,
+ streaming_service=streaming_service,
+ content_builder=content_builder,
+ thinking_metadata=state.span_metadata_if_active(),
+ )
+ if frame:
+ yield frame
+ return
+
if name == "document_created":
frame = handle_document_created(data, streaming_service=streaming_service)
if frame:
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/custom_events.py b/surfsense_backend/app/tasks/chat/streaming/handlers/custom_events.py
index 81116e205..dbba47d44 100644
--- a/surfsense_backend/app/tasks/chat/streaming/handlers/custom_events.py
+++ b/surfsense_backend/app/tasks/chat/streaming/handlers/custom_events.py
@@ -54,6 +54,53 @@ def handle_report_progress(
return frame, new_items
+def _scraper_progress_label(data: dict[str, Any]) -> str:
+ """Build a one-line human status from a ``scraper_progress`` event."""
+ message = data.get("message")
+ phase = data.get("phase", "")
+ current = data.get("current")
+ total = data.get("total")
+ label = message or (phase.replace("_", " ").capitalize() if phase else "Working")
+ if current is not None:
+ counter = f"{current}/{total}" if total else str(current)
+ label = f"{label} ({counter})"
+ return label
+
+
+def handle_scraper_progress(
+ data: dict[str, Any],
+ *,
+ last_active_step_id: str | None,
+ last_active_step_title: str,
+ last_active_step_items: list[str],
+ streaming_service: Any,
+ content_builder: Any | None,
+ thinking_metadata: dict[str, Any] | None = None,
+) -> tuple[str | None, list[str]]:
+ """Surface a scraper's live progress as an evolving thinking-step item.
+
+ Scraper capability tool calls own a fresh thinking step (see ``tool_start``),
+ so we show a single latest-status line rather than accumulating every event.
+ Returns (frame or None, items after update).
+ """
+ if not last_active_step_id:
+ return None, last_active_step_items
+ label = _scraper_progress_label(data)
+ if not label:
+ return None, last_active_step_items
+ new_items = [label]
+ frame = emit_thinking_step_frame(
+ streaming_service=streaming_service,
+ content_builder=content_builder,
+ step_id=last_active_step_id,
+ title=last_active_step_title,
+ status="in_progress",
+ items=new_items,
+ metadata=thinking_metadata,
+ )
+ return frame, new_items
+
+
def handle_document_created(
data: dict[str, Any], *, streaming_service: Any
) -> str | None:
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/emission.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/emission.py
deleted file mode 100644
index 293d2a1e9..000000000
--- a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/emission.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""scrape_webpage: redacted payload + terminal summary."""
-
-from __future__ import annotations
-
-from collections.abc import Iterator
-
-from app.tasks.chat.streaming.handlers.tools.emission_context import (
- ToolCompletionEmissionContext,
-)
-
-
-def iter_completion_emission_frames(
- ctx: ToolCompletionEmissionContext,
-) -> Iterator[str]:
- out = ctx.tool_output
- if isinstance(out, dict):
- display_output = {k: v for k, v in out.items() if k != "content"}
- if "content" in out:
- content = out.get("content", "")
- display_output["content_preview"] = (
- content[:500] + "..." if len(content) > 500 else content
- )
- yield ctx.emit_tool_output_card(display_output)
- else:
- yield ctx.emit_tool_output_card({"result": out})
-
- if isinstance(out, dict) and "error" not in out:
- title = out.get("title", "Webpage")
- word_count = out.get("word_count", 0)
- yield ctx.streaming_service.format_terminal_info(
- f"Scraped: {title[:40]}{'...' if len(title) > 40 else ''} ({word_count:,} words)",
- "success",
- )
- else:
- error_msg = (
- out.get("error", "Failed to scrape")
- if isinstance(out, dict)
- else "Failed to scrape"
- )
- yield ctx.streaming_service.format_terminal_info(
- f"Scrape failed: {error_msg}",
- "error",
- )
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/shared/tool_input.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/shared/tool_input.py
deleted file mode 100644
index 581f0e64a..000000000
--- a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/shared/tool_input.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Tool-call args for scrape_webpage thinking."""
-
-from __future__ import annotations
-
-from typing import Any
-
-
-def as_tool_input_dict(tool_input: Any) -> dict[str, Any]:
- return tool_input if isinstance(tool_input, dict) else {}
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/thinking.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/thinking.py
deleted file mode 100644
index 8a04acbe6..000000000
--- a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/thinking.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""scrape_webpage: thinking-step copy."""
-
-from __future__ import annotations
-
-from typing import Any
-
-from app.tasks.chat.streaming.handlers.tools.scrape_webpage.shared.tool_input import (
- as_tool_input_dict,
-)
-from app.tasks.chat.streaming.handlers.tools.shared.model import (
- ToolStartThinking,
-)
-
-
-def resolve_start_thinking(tool_name: str, tool_input: Any) -> ToolStartThinking:
- del tool_name
- d = as_tool_input_dict(tool_input)
- url = d.get("url", "") if isinstance(tool_input, dict) else str(tool_input)
- return ToolStartThinking(
- title="Scraping webpage",
- items=[f"URL: {url[:80]}{'...' if len(url) > 80 else ''}"],
- )
-
-
-def resolve_completed_thinking(
- tool_name: str,
- tool_output: Any,
- last_items: list[str],
-) -> tuple[str, list[str]]:
- del tool_name
- items = last_items
- if isinstance(tool_output, dict):
- title = tool_output.get("title", "Webpage")
- word_count = tool_output.get("word_count", 0)
- has_error = "error" in tool_output
- if has_error:
- completed = [
- *items,
- f"Error: {tool_output.get('error', 'Failed to scrape')[:50]}",
- ]
- else:
- completed = [
- *items,
- f"Title: {title[:50]}{'...' if len(title) > 50 else ''}",
- f"Extracted: {word_count:,} words",
- ]
- else:
- completed = [*items, "Content extracted"]
- return ("Scraping webpage", completed)
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_discover/__init__.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_discover/__init__.py
new file mode 100644
index 000000000..9ad4346a6
--- /dev/null
+++ b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_discover/__init__.py
@@ -0,0 +1 @@
+"""web_discover tool: UI emission for the web.discover capability (05)."""
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_discover/emission.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_discover/emission.py
new file mode 100644
index 000000000..4f0592889
--- /dev/null
+++ b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_discover/emission.py
@@ -0,0 +1,29 @@
+"""web_discover: ranked-hits card + a result-count terminal line."""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+from app.tasks.chat.streaming.handlers.tools.emission_context import (
+ ToolCompletionEmissionContext,
+)
+
+
+def iter_completion_emission_frames(
+ ctx: ToolCompletionEmissionContext,
+) -> Iterator[str]:
+ out = ctx.tool_output
+ if not isinstance(out, dict):
+ message = str(out)
+ yield ctx.emit_tool_output_card({"status": "error", "message": message})
+ yield ctx.streaming_service.format_terminal_info(message, "error")
+ return
+
+ hits = out.get("hits") or []
+ yield ctx.emit_tool_output_card(
+ {"status": "completed", "hits": hits, "count": len(hits)}
+ )
+ level = "success" if hits else "info"
+ yield ctx.streaming_service.format_terminal_info(
+ f"Found {len(hits)} result(s)", level
+ )
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_scrape/__init__.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_scrape/__init__.py
new file mode 100644
index 000000000..2282f2277
--- /dev/null
+++ b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_scrape/__init__.py
@@ -0,0 +1 @@
+"""web_scrape tool: UI emission for the web.scrape capability (05)."""
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_scrape/emission.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_scrape/emission.py
new file mode 100644
index 000000000..dcc0a494d
--- /dev/null
+++ b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_scrape/emission.py
@@ -0,0 +1,54 @@
+"""web_scrape: per-page card (content previewed) + a scraped-count terminal line."""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+from app.tasks.chat.streaming.handlers.tools.emission_context import (
+ ToolCompletionEmissionContext,
+)
+
+_PREVIEW_CHARS = 500
+
+
+def iter_completion_emission_frames(
+ ctx: ToolCompletionEmissionContext,
+) -> Iterator[str]:
+ out = ctx.tool_output
+ if not isinstance(out, dict):
+ message = str(out)
+ yield ctx.emit_tool_output_card({"status": "error", "message": message})
+ yield ctx.streaming_service.format_terminal_info(message, "error")
+ return
+
+ rows = out.get("rows") or []
+ pages = [_page(row) for row in rows]
+ succeeded = sum(1 for row in rows if row.get("status") == "success")
+
+ yield ctx.emit_tool_output_card(
+ {
+ "status": "completed",
+ "pages": pages,
+ "succeeded": succeeded,
+ "total": len(rows),
+ }
+ )
+ level = "success" if succeeded else "error"
+ yield ctx.streaming_service.format_terminal_info(
+ f"Scraped {succeeded}/{len(rows)} page(s)", level
+ )
+
+
+def _page(row: dict) -> dict:
+ """A card-safe view of one row: metadata kept, content bounded to a preview."""
+ page: dict = {"url": row.get("url"), "status": row.get("status")}
+ if row.get("metadata"):
+ page["metadata"] = row["metadata"]
+ content = row.get("content")
+ if content:
+ page["content_preview"] = (
+ content[:_PREVIEW_CHARS] + "…" if len(content) > _PREVIEW_CHARS else content
+ )
+ if row.get("error"):
+ page["error"] = row["error"]
+ return page
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_search/emission.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_search/emission.py
deleted file mode 100644
index 3efe45d0c..000000000
--- a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/web_search/emission.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""web_search: citations parsed from provider XML."""
-
-from __future__ import annotations
-
-import re
-from collections.abc import Iterator
-
-from app.tasks.chat.streaming.handlers.tools.emission_context import (
- ToolCompletionEmissionContext,
-)
-
-
-def iter_completion_emission_frames(
- ctx: ToolCompletionEmissionContext,
-) -> Iterator[str]:
- out = ctx.tool_output
- xml = out.get("result", str(out)) if isinstance(out, dict) else str(out)
- citations: dict[str, dict[str, str]] = {}
- for m in re.finditer(
- r" \s* ",
- xml,
- ):
- title, url = m.group(1).strip(), m.group(2).strip()
- if url.startswith("http") and url not in citations:
- citations[url] = {"title": title}
- for m in re.finditer(
- r" ",
- xml,
- ):
- chunk_url, content = m.group(1).strip(), m.group(2).strip()
- if chunk_url.startswith("http") and chunk_url in citations and content:
- citations[chunk_url]["snippet"] = (
- content[:200] + "…" if len(content) > 200 else content
- )
- yield ctx.emit_tool_output_card(
- {"status": "completed", "citations": citations},
- )
diff --git a/surfsense_backend/app/tasks/composio_indexer.py b/surfsense_backend/app/tasks/composio_indexer.py
index 0518ad2a6..157988810 100644
--- a/surfsense_backend/app/tasks/composio_indexer.py
+++ b/surfsense_backend/app/tasks/composio_indexer.py
@@ -84,7 +84,7 @@ def get_indexer_function(toolkit_id: str):
async def index_composio_connector(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -101,7 +101,7 @@ async def index_composio_connector(
Args:
session: Database session
connector_id: ID of the Composio connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for filtering (YYYY-MM-DD format)
end_date: End date for filtering (YYYY-MM-DD format)
@@ -112,7 +112,7 @@ async def index_composio_connector(
Returns:
Tuple of (number_of_indexed_items, number_of_skipped_items, error_message or None)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -180,7 +180,7 @@ async def index_composio_connector(
"session": session,
"connector": connector,
"connector_id": connector_id,
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"user_id": user_id,
"task_logger": task_logger,
"log_entry": log_entry,
diff --git a/surfsense_backend/app/tasks/connector_indexers/__init__.py b/surfsense_backend/app/tasks/connector_indexers/__init__.py
index 218f21066..0f867bdcf 100644
--- a/surfsense_backend/app/tasks/connector_indexers/__init__.py
+++ b/surfsense_backend/app/tasks/connector_indexers/__init__.py
@@ -14,12 +14,10 @@ from .google_calendar_indexer import index_google_calendar_events
from .google_drive_indexer import index_google_drive_files
from .google_gmail_indexer import index_google_gmail_messages
from .notion_indexer import index_notion_pages
-from .webcrawler_indexer import index_crawled_urls
__all__ = [
"index_bookstack_pages",
"index_confluence_pages",
- "index_crawled_urls",
"index_elasticsearch_documents",
"index_github_repos",
"index_google_calendar_events",
diff --git a/surfsense_backend/app/tasks/connector_indexers/airtable_indexer.py b/surfsense_backend/app/tasks/connector_indexers/airtable_indexer.py
index e2a1b109a..9958031b1 100644
--- a/surfsense_backend/app/tasks/connector_indexers/airtable_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/airtable_indexer.py
@@ -44,7 +44,7 @@ HEARTBEAT_INTERVAL_SECONDS = 30
async def index_airtable_records(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -58,7 +58,7 @@ async def index_airtable_records(
Args:
session: Database session
connector_id: ID of the Airtable connector
- search_space_id: ID of the search space to store documents in
+ workspace_id: ID of the workspace to store documents in
user_id: ID of the user
start_date: Start date for filtering records (YYYY-MM-DD)
end_date: End date for filtering records (YYYY-MM-DD)
@@ -69,7 +69,7 @@ async def index_airtable_records(
Returns:
Tuple of (number_of_documents_processed, error_message)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="airtable_indexing",
source="connector_indexing_task",
@@ -259,12 +259,12 @@ async def index_airtable_records(
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.AIRTABLE_CONNECTOR,
record_id,
- search_space_id,
+ workspace_id,
)
# Generate content hash
content_hash = generate_content_hash(
- markdown_content, search_space_id
+ markdown_content, workspace_id
)
# Check if document with this unique identifier already exists
@@ -323,7 +323,7 @@ async def index_airtable_records(
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=record_id,
document_type=DocumentType.AIRTABLE_CONNECTOR,
document_metadata={
diff --git a/surfsense_backend/app/tasks/connector_indexers/base.py b/surfsense_backend/app/tasks/connector_indexers/base.py
index 9408874ca..3a982cc4c 100644
--- a/surfsense_backend/app/tasks/connector_indexers/base.py
+++ b/surfsense_backend/app/tasks/connector_indexers/base.py
@@ -137,7 +137,7 @@ async def mark_connector_documents_failed(
session: AsyncSession,
*,
document_type: DocumentType,
- search_space_id: int,
+ workspace_id: int,
failures: list[tuple[str, str]],
) -> int:
"""Transition placeholder/in-progress documents to ``failed`` by source id.
@@ -159,7 +159,7 @@ async def mark_connector_documents_failed(
if not unique_id:
continue
uid_hash = compute_identifier_hash(
- document_type.value, unique_id, search_space_id
+ document_type.value, unique_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, uid_hash)
if existing is None:
diff --git a/surfsense_backend/app/tasks/connector_indexers/bookstack_indexer.py b/surfsense_backend/app/tasks/connector_indexers/bookstack_indexer.py
index 6471ffb00..e00936cac 100644
--- a/surfsense_backend/app/tasks/connector_indexers/bookstack_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/bookstack_indexer.py
@@ -45,7 +45,7 @@ HEARTBEAT_INTERVAL_SECONDS = 30
async def index_bookstack_pages(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -58,7 +58,7 @@ async def index_bookstack_pages(
Args:
session: Database session
connector_id: ID of the BookStack connector
- search_space_id: ID of the search space to store documents in
+ workspace_id: ID of the workspace to store documents in
user_id: User ID
start_date: Start date for indexing (YYYY-MM-DD format)
end_date: End date for indexing (YYYY-MM-DD format)
@@ -68,7 +68,7 @@ async def index_bookstack_pages(
Returns:
Tuple containing (number of documents indexed, error message or None)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -242,11 +242,11 @@ async def index_bookstack_pages(
# Generate unique identifier hash for this BookStack page
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.BOOKSTACK_CONNECTOR, page_id, search_space_id
+ DocumentType.BOOKSTACK_CONNECTOR, page_id, workspace_id
)
# Generate content hash
- content_hash = generate_content_hash(full_content, search_space_id)
+ content_hash = generate_content_hash(full_content, workspace_id)
# Check if document with this unique identifier already exists
existing_document = await check_document_by_unique_identifier(
@@ -307,7 +307,7 @@ async def index_bookstack_pages(
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=page_name,
document_type=DocumentType.BOOKSTACK_CONNECTOR,
document_metadata={
diff --git a/surfsense_backend/app/tasks/connector_indexers/clickup_indexer.py b/surfsense_backend/app/tasks/connector_indexers/clickup_indexer.py
index 91763129f..8d67cad9e 100644
--- a/surfsense_backend/app/tasks/connector_indexers/clickup_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/clickup_indexer.py
@@ -44,7 +44,7 @@ HEARTBEAT_INTERVAL_SECONDS = 30
async def index_clickup_tasks(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -57,7 +57,7 @@ async def index_clickup_tasks(
Args:
session: Database session
connector_id: ID of the ClickUp connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for filtering tasks (YYYY-MM-DD format)
end_date: End date for filtering tasks (YYYY-MM-DD format)
@@ -67,7 +67,7 @@ async def index_clickup_tasks(
Returns:
Tuple of (number of indexed tasks, error message if any)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -243,11 +243,11 @@ async def index_clickup_tasks(
# Generate unique identifier hash for this ClickUp task
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.CLICKUP_CONNECTOR, task_id, search_space_id
+ DocumentType.CLICKUP_CONNECTOR, task_id, workspace_id
)
# Generate content hash
- content_hash = generate_content_hash(task_content, search_space_id)
+ content_hash = generate_content_hash(task_content, workspace_id)
# Check if document with this unique identifier already exists
existing_document = await check_document_by_unique_identifier(
@@ -310,7 +310,7 @@ async def index_clickup_tasks(
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=task_name,
document_type=DocumentType.CLICKUP_CONNECTOR,
document_metadata={
diff --git a/surfsense_backend/app/tasks/connector_indexers/confluence_indexer.py b/surfsense_backend/app/tasks/connector_indexers/confluence_indexer.py
index 53c438197..ec26757d4 100644
--- a/surfsense_backend/app/tasks/connector_indexers/confluence_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/confluence_indexer.py
@@ -34,7 +34,7 @@ def _build_connector_doc(
full_content: str,
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
"""Map a raw Confluence page dict to a ConnectorDocument."""
@@ -58,7 +58,7 @@ def _build_connector_doc(
source_markdown=full_content,
unique_id=page_id,
document_type=DocumentType.CONFLUENCE_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata=metadata,
@@ -68,7 +68,7 @@ def _build_connector_doc(
async def index_confluence_pages(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -76,7 +76,7 @@ async def index_confluence_pages(
on_heartbeat_callback: HeartbeatCallbackType | None = None,
) -> tuple[int, int, str | None]:
"""Index Confluence pages and comments."""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="confluence_pages_indexing",
source="connector_indexing_task",
@@ -197,7 +197,7 @@ async def index_confluence_pages(
title=page.get("title", ""),
document_type=DocumentType.CONFLUENCE_CONNECTOR,
unique_id=page.get("id", ""),
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -259,7 +259,7 @@ async def index_confluence_pages(
page,
full_content,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -309,7 +309,7 @@ async def index_confluence_pages(
await mark_connector_documents_failed(
session,
document_type=DocumentType.CONFLUENCE_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=stuck_placeholders,
)
diff --git a/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py b/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py
index 8c5bd8f0e..564951abe 100644
--- a/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py
@@ -116,7 +116,7 @@ def _build_batch_document_string(
async def index_discord_messages(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -139,7 +139,7 @@ async def index_discord_messages(
Args:
session: Database session
connector_id: ID of the Discord connector
- search_space_id: ID of the search space to store documents in
+ workspace_id: ID of the workspace to store documents in
user_id: ID of the user
start_date: Start date for indexing (YYYY-MM-DD format)
end_date: End date for indexing (YYYY-MM-DD format)
@@ -150,7 +150,7 @@ async def index_discord_messages(
Returns:
Tuple containing (number of documents indexed, error message or None)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -506,12 +506,12 @@ async def index_discord_messages(
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.DISCORD_CONNECTOR,
unique_identifier,
- search_space_id,
+ workspace_id,
)
# Generate content hash
content_hash = generate_content_hash(
- combined_document_string, search_space_id
+ combined_document_string, workspace_id
)
# Check if document with this unique identifier already exists
@@ -579,7 +579,7 @@ async def index_discord_messages(
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=f"{guild_name}#{channel_name}",
document_type=DocumentType.DISCORD_CONNECTOR,
document_metadata={
diff --git a/surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py b/surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py
index 9bf290d85..4eb44b976 100644
--- a/surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py
@@ -46,7 +46,7 @@ logger = logging.getLogger(__name__)
async def _should_skip_file(
session: AsyncSession,
file: dict,
- search_space_id: int,
+ workspace_id: int,
) -> tuple[bool, str | None]:
"""Pre-filter: detect unchanged / rename-only files."""
file_id = file.get("id", "")
@@ -61,14 +61,14 @@ async def _should_skip_file(
return True, "missing file_id"
primary_hash = compute_identifier_hash(
- DocumentType.DROPBOX_FILE.value, file_id, search_space_id
+ DocumentType.DROPBOX_FILE.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, primary_hash)
if not existing:
result = await session.execute(
select(Document).where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.DROPBOX_FILE,
cast(Document.document_metadata["dropbox_file_id"], String) == file_id,
)
@@ -130,7 +130,7 @@ def _build_connector_doc(
dropbox_metadata: dict,
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
file_id = file.get("id", "")
@@ -148,7 +148,7 @@ def _build_connector_doc(
source_markdown=markdown,
unique_id=file_id,
document_type=DocumentType.DROPBOX_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata=metadata,
@@ -160,7 +160,7 @@ async def _download_files_parallel(
files: list[dict],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
max_concurrency: int = 3,
on_heartbeat: HeartbeatCallbackType | None = None,
@@ -194,7 +194,7 @@ async def _download_files_parallel(
markdown,
db_metadata,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
async with hb_lock:
@@ -239,7 +239,7 @@ async def _download_and_index(
files: list[dict],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
on_heartbeat: HeartbeatCallbackType | None = None,
vision_llm=None,
@@ -249,7 +249,7 @@ async def _download_and_index(
dropbox_client,
files,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat,
vision_llm=vision_llm,
@@ -260,7 +260,7 @@ async def _download_and_index(
await mark_connector_documents_failed(
session,
document_type=DocumentType.DROPBOX_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=failed_files,
)
@@ -277,17 +277,17 @@ async def _download_and_index(
return batch_indexed, len(failed_files) + batch_failed
-async def _remove_document(session: AsyncSession, file_id: str, search_space_id: int):
+async def _remove_document(session: AsyncSession, file_id: str, workspace_id: int):
"""Remove a document that was deleted in Dropbox."""
primary_hash = compute_identifier_hash(
- DocumentType.DROPBOX_FILE.value, file_id, search_space_id
+ DocumentType.DROPBOX_FILE.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, primary_hash)
if not existing:
result = await session.execute(
select(Document).where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.DROPBOX_FILE,
cast(Document.document_metadata["dropbox_file_id"], String) == file_id,
)
@@ -302,7 +302,7 @@ async def _index_with_delta_sync(
dropbox_client: DropboxClient,
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
cursor: str,
task_logger: TaskLoggingService,
@@ -354,14 +354,14 @@ async def _index_with_delta_sync(
name = entry.get("name", "")
file_id = entry.get("id", "")
if file_id:
- await _remove_document(session, file_id, search_space_id)
+ await _remove_document(session, file_id, workspace_id)
logger.debug(f"Processed deletion: {name or path_lower}")
continue
if tag != "file":
continue
- skip, msg = await _should_skip_file(session, entry, search_space_id)
+ skip, msg = await _should_skip_file(session, entry, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -378,7 +378,7 @@ async def _index_with_delta_sync(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat_callback,
vision_llm=vision_llm,
@@ -396,7 +396,7 @@ async def _index_full_scan(
dropbox_client: DropboxClient,
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_path: str,
folder_name: str,
@@ -448,7 +448,7 @@ async def _index_full_scan(
for file in all_files[:max_files]:
if incremental_sync:
- skip, msg = await _should_skip_file(session, file, search_space_id)
+ skip, msg = await _should_skip_file(session, file, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -491,7 +491,7 @@ async def _index_full_scan(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat_callback,
vision_llm=vision_llm,
@@ -517,7 +517,7 @@ async def _index_selected_files(
file_paths: list[tuple[str, str | None]],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
incremental_sync: bool = True,
on_heartbeat: HeartbeatCallbackType | None = None,
@@ -542,7 +542,7 @@ async def _index_selected_files(
continue
if incremental_sync:
- skip, msg = await _should_skip_file(session, file, search_space_id)
+ skip, msg = await _should_skip_file(session, file, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -580,7 +580,7 @@ async def _index_selected_files(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat,
vision_llm=vision_llm,
@@ -598,7 +598,7 @@ async def _index_selected_files(
async def index_dropbox_files(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict,
) -> tuple[int, int, str | None, int]:
@@ -615,7 +615,7 @@ async def index_dropbox_files(
}
}
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="dropbox_files_indexing",
source="connector_indexing_task",
@@ -650,7 +650,7 @@ async def index_dropbox_files(
if connector_enable_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm = await get_vision_llm(session, search_space_id)
+ vision_llm = await get_vision_llm(session, workspace_id)
dropbox_client = DropboxClient(session, connector_id)
@@ -677,7 +677,7 @@ async def index_dropbox_files(
session,
file_tuples,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
incremental_sync=incremental_sync,
vision_llm=vision_llm,
@@ -708,7 +708,7 @@ async def index_dropbox_files(
dropbox_client,
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
saved_cursor,
task_logger,
@@ -724,7 +724,7 @@ async def index_dropbox_files(
dropbox_client,
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
folder_path,
folder_name,
diff --git a/surfsense_backend/app/tasks/connector_indexers/elasticsearch_indexer.py b/surfsense_backend/app/tasks/connector_indexers/elasticsearch_indexer.py
index ba0aa3445..ed0553b28 100644
--- a/surfsense_backend/app/tasks/connector_indexers/elasticsearch_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/elasticsearch_indexer.py
@@ -45,7 +45,7 @@ logger = logging.getLogger(__name__)
async def index_elasticsearch_documents(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str,
end_date: str,
@@ -58,7 +58,7 @@ async def index_elasticsearch_documents(
Args:
session: Database session
connector_id: Elasticsearch connector ID
- search_space_id: Search space ID
+ workspace_id: Workspace ID
user_id: User ID
start_date: Start date for indexing (not used for Elasticsearch, kept for compatibility)
end_date: End date for indexing (not used for Elasticsearch, kept for compatibility)
@@ -68,7 +68,7 @@ async def index_elasticsearch_documents(
Returns:
Tuple of (number of documents processed, error message if any)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="elasticsearch_indexing",
source="connector_indexing_task",
@@ -231,14 +231,14 @@ async def index_elasticsearch_documents(
continue
# Create content hash
- content_hash = generate_content_hash(content, search_space_id)
+ content_hash = generate_content_hash(content, workspace_id)
# Build source-unique identifier and hash (prefer source id dedupe)
source_identifier = f"{hit.get('_index', index_name)}:{doc_id}"
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.ELASTICSEARCH_CONNECTOR,
source_identifier,
- search_space_id,
+ workspace_id,
)
# Two-step duplicate detection: first by source-unique id, then by content hash
@@ -304,7 +304,7 @@ async def index_elasticsearch_documents(
unique_identifier_hash=unique_identifier_hash,
document_type=DocumentType.ELASTICSEARCH_CONNECTOR,
document_metadata=metadata,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
embedding=None,
chunks=[], # Empty at creation - safe for async
status=DocumentStatus.pending(), # Pending until processing starts
diff --git a/surfsense_backend/app/tasks/connector_indexers/github_indexer.py b/surfsense_backend/app/tasks/connector_indexers/github_indexer.py
index 557c2ce71..423f5dc1d 100644
--- a/surfsense_backend/app/tasks/connector_indexers/github_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/github_indexer.py
@@ -51,7 +51,7 @@ MAX_DIGEST_CHARS = 500_000 # ~125k tokens
async def index_github_repos(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None, # Ignored - GitHub indexes full repo snapshots
end_date: str | None = None, # Ignored - GitHub indexes full repo snapshots
@@ -71,7 +71,7 @@ async def index_github_repos(
Args:
session: Database session
connector_id: ID of the GitHub connector
- search_space_id: ID of the search space to store documents in
+ workspace_id: ID of the workspace to store documents in
user_id: ID of the user
start_date: Ignored - kept for API compatibility
end_date: Ignored - kept for API compatibility
@@ -83,7 +83,7 @@ async def index_github_repos(
"""
# Note: start_date and end_date are intentionally unused
_ = start_date, end_date
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -220,12 +220,12 @@ async def index_github_repos(
# Generate unique identifier based on repo name
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.GITHUB_CONNECTOR, repo_full_name, search_space_id
+ DocumentType.GITHUB_CONNECTOR, repo_full_name, workspace_id
)
# Generate content hash from digest
full_content = digest.full_digest
- content_hash = generate_content_hash(full_content, search_space_id)
+ content_hash = generate_content_hash(full_content, workspace_id)
# Check if document with this unique identifier already exists
existing_document = await check_document_by_unique_identifier(
@@ -278,7 +278,7 @@ async def index_github_repos(
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=repo_full_name,
document_type=DocumentType.GITHUB_CONNECTOR,
document_metadata={
diff --git a/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py b/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py
index 51df39171..c70849a08 100644
--- a/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py
@@ -51,7 +51,7 @@ def _build_connector_doc(
event_markdown: str,
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
"""Map a raw Google Calendar API event dict to a ConnectorDocument."""
@@ -82,7 +82,7 @@ def _build_connector_doc(
source_markdown=event_markdown,
unique_id=event_id,
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata=metadata,
@@ -92,7 +92,7 @@ def _build_connector_doc(
async def index_google_calendar_events(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -105,7 +105,7 @@ async def index_google_calendar_events(
Args:
session: Database session
connector_id: ID of the Google Calendar connector
- search_space_id: ID of the search space to store documents in
+ workspace_id: ID of the workspace to store documents in
user_id: User ID
start_date: Start date for indexing (YYYY-MM-DD format). Can be in the past or future.
end_date: End date for indexing (YYYY-MM-DD format). Can be in the future to index upcoming events.
@@ -116,7 +116,7 @@ async def index_google_calendar_events(
Returns:
Tuple containing (number of documents indexed, number of documents skipped, error message or None)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="google_calendar_events_indexing",
@@ -374,7 +374,7 @@ async def index_google_calendar_events(
title=event.get("summary", "No Title"),
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
unique_id=event.get("id", ""),
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -413,7 +413,7 @@ async def index_google_calendar_events(
event,
event_markdown,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -462,7 +462,7 @@ async def index_google_calendar_events(
await mark_connector_documents_failed(
session,
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=stuck_placeholders,
)
diff --git a/surfsense_backend/app/tasks/connector_indexers/google_drive_indexer.py b/surfsense_backend/app/tasks/connector_indexers/google_drive_indexer.py
index 37de66ffd..d6efd847b 100644
--- a/surfsense_backend/app/tasks/connector_indexers/google_drive_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/google_drive_indexer.py
@@ -273,7 +273,7 @@ def _build_drive_client_for_connector(
async def _should_skip_file(
session: AsyncSession,
file: dict,
- search_space_id: int,
+ workspace_id: int,
) -> tuple[bool, str | None]:
"""Pre-filter: detect unchanged / rename-only files.
@@ -295,13 +295,13 @@ async def _should_skip_file(
# --- locate existing document ---
primary_hash = compute_identifier_hash(
- DocumentType.GOOGLE_DRIVE_FILE.value, file_id, search_space_id
+ DocumentType.GOOGLE_DRIVE_FILE.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, primary_hash)
if not existing:
legacy_hash = compute_identifier_hash(
- DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR.value, file_id, search_space_id
+ DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, legacy_hash)
if existing:
@@ -313,7 +313,7 @@ async def _should_skip_file(
if not existing:
result = await session.execute(
select(Document).where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type.in_(
[
DocumentType.GOOGLE_DRIVE_FILE,
@@ -385,7 +385,7 @@ def _build_connector_doc(
drive_metadata: dict,
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
"""Build a ConnectorDocument from Drive file metadata + extracted markdown."""
@@ -404,7 +404,7 @@ def _build_connector_doc(
source_markdown=markdown,
unique_id=file_id,
document_type=DocumentType.GOOGLE_DRIVE_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata=metadata,
@@ -416,7 +416,7 @@ async def _create_drive_placeholders(
files: list[dict],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> None:
"""Create placeholder document rows for discovered Drive files.
@@ -438,7 +438,7 @@ async def _create_drive_placeholders(
title=file_name,
document_type=DocumentType.GOOGLE_DRIVE_FILE,
unique_id=file_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -460,7 +460,7 @@ async def _download_files_parallel(
files: list[dict],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
max_concurrency: int = 3,
on_heartbeat: HeartbeatCallbackType | None = None,
@@ -494,7 +494,7 @@ async def _download_files_parallel(
markdown,
drive_metadata,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
async with hb_lock:
@@ -538,7 +538,7 @@ async def _process_single_file(
session: AsyncSession,
file: dict,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
vision_llm=None,
) -> tuple[int, int, int]:
@@ -549,7 +549,7 @@ async def _process_single_file(
file_name = file.get("name", "Unknown")
try:
- skip, msg = await _should_skip_file(session, file, search_space_id)
+ skip, msg = await _should_skip_file(session, file, workspace_id)
if skip:
if msg and "renamed" in msg.lower():
return 1, 0, 0
@@ -572,7 +572,7 @@ async def _process_single_file(
await mark_connector_documents_failed(
session,
document_type=DocumentType.GOOGLE_DRIVE_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=[(file_id, f"Download/ETL failed: {reason}")],
)
return 0, 1, 0
@@ -582,7 +582,7 @@ async def _process_single_file(
markdown,
drive_metadata,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -611,23 +611,23 @@ async def _process_single_file(
return 0, 0, 1
-async def _remove_document(session: AsyncSession, file_id: str, search_space_id: int):
+async def _remove_document(session: AsyncSession, file_id: str, workspace_id: int):
"""Remove a document that was deleted in Drive."""
primary_hash = compute_identifier_hash(
- DocumentType.GOOGLE_DRIVE_FILE.value, file_id, search_space_id
+ DocumentType.GOOGLE_DRIVE_FILE.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, primary_hash)
if not existing:
legacy_hash = compute_identifier_hash(
- DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR.value, file_id, search_space_id
+ DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, legacy_hash)
if not existing:
result = await session.execute(
select(Document).where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type.in_(
[
DocumentType.GOOGLE_DRIVE_FILE,
@@ -651,7 +651,7 @@ async def _download_and_index(
files: list[dict],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
on_heartbeat: HeartbeatCallbackType | None = None,
vision_llm=None,
@@ -664,7 +664,7 @@ async def _download_and_index(
drive_client,
files,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat,
vision_llm=vision_llm,
@@ -676,7 +676,7 @@ async def _download_and_index(
await mark_connector_documents_failed(
session,
document_type=DocumentType.GOOGLE_DRIVE_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=failed_files,
)
@@ -699,7 +699,7 @@ async def _index_selected_files(
file_ids: list[tuple[str, str | None]],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
on_heartbeat: HeartbeatCallbackType | None = None,
vision_llm=None,
@@ -728,7 +728,7 @@ async def _index_selected_files(
errors.append(f"File '{display}': {error or 'File not found'}")
continue
- skip, msg = await _should_skip_file(session, file, search_space_id)
+ skip, msg = await _should_skip_file(session, file, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -757,7 +757,7 @@ async def _index_selected_files(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -766,7 +766,7 @@ async def _index_selected_files(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat,
vision_llm=vision_llm,
@@ -791,7 +791,7 @@ async def _index_full_scan(
session: AsyncSession,
connector: object,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_id: str | None,
folder_name: str,
@@ -865,7 +865,7 @@ async def _index_full_scan(
files_processed += 1
- skip, msg = await _should_skip_file(session, file, search_space_id)
+ skip, msg = await _should_skip_file(session, file, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -920,7 +920,7 @@ async def _index_full_scan(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -932,7 +932,7 @@ async def _index_full_scan(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat_callback,
vision_llm=vision_llm,
@@ -957,7 +957,7 @@ async def _index_with_delta_sync(
session: AsyncSession,
connector: object,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_id: str | None,
start_page_token: str,
@@ -1018,14 +1018,14 @@ async def _index_with_delta_sync(
if change_type in ["removed", "trashed"]:
fid = change.get("fileId")
if fid:
- await _remove_document(session, fid, search_space_id)
+ await _remove_document(session, fid, workspace_id)
continue
file = change.get("file")
if not file:
continue
- skip, msg = await _should_skip_file(session, file, search_space_id)
+ skip, msg = await _should_skip_file(session, file, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -1062,7 +1062,7 @@ async def _index_with_delta_sync(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -1074,7 +1074,7 @@ async def _index_with_delta_sync(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat_callback,
vision_llm=vision_llm,
@@ -1102,7 +1102,7 @@ async def _index_with_delta_sync(
async def index_google_drive_files(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_id: str | None = None,
folder_name: str | None = None,
@@ -1116,7 +1116,7 @@ async def index_google_drive_files(
Returns (indexed, skipped, error_or_none, unsupported_count).
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="google_drive_files_indexing",
source="connector_indexing_task",
@@ -1166,7 +1166,7 @@ async def index_google_drive_files(
if connector_enable_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm = await get_vision_llm(session, search_space_id)
+ vision_llm = await get_vision_llm(session, workspace_id)
if not folder_id:
error_msg = "folder_id is required for Google Drive indexing"
await task_logger.log_task_failure(
@@ -1198,7 +1198,7 @@ async def index_google_drive_files(
session,
connector,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
target_folder_id,
start_page_token,
@@ -1216,7 +1216,7 @@ async def index_google_drive_files(
session,
connector,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
target_folder_id,
target_folder_name,
@@ -1241,7 +1241,7 @@ async def index_google_drive_files(
session,
connector,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
target_folder_id,
target_folder_name,
@@ -1317,13 +1317,13 @@ async def index_google_drive_files(
async def index_google_drive_single_file(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
file_id: str,
file_name: str | None = None,
) -> tuple[int, str | None]:
"""Index a single Google Drive file by its ID."""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="google_drive_single_file_indexing",
source="connector_indexing_task",
@@ -1366,7 +1366,7 @@ async def index_google_drive_single_file(
if connector_enable_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm = await get_vision_llm(session, search_space_id)
+ vision_llm = await get_vision_llm(session, workspace_id)
file, error = await get_file_by_id(drive_client, file_id)
if error or not file:
error_msg = f"Failed to fetch file {file_id}: {error or 'File not found'}"
@@ -1382,7 +1382,7 @@ async def index_google_drive_single_file(
session,
file,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
vision_llm=vision_llm,
)
@@ -1430,7 +1430,7 @@ async def index_google_drive_single_file(
async def index_google_drive_selected_files(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
files: list[tuple[str, str | None]],
on_heartbeat_callback: HeartbeatCallbackType | None = None,
@@ -1442,7 +1442,7 @@ async def index_google_drive_selected_files(
Returns (indexed_count, skipped_count, errors).
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="google_drive_selected_files_indexing",
source="connector_indexing_task",
@@ -1485,13 +1485,13 @@ async def index_google_drive_selected_files(
if connector_enable_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm = await get_vision_llm(session, search_space_id)
+ vision_llm = await get_vision_llm(session, workspace_id)
indexed, skipped, unsupported, errors = await _index_selected_files(
drive_client,
session,
files,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat_callback,
vision_llm=vision_llm,
diff --git a/surfsense_backend/app/tasks/connector_indexers/google_gmail_indexer.py b/surfsense_backend/app/tasks/connector_indexers/google_gmail_indexer.py
index 25da96b61..3f5fad889 100644
--- a/surfsense_backend/app/tasks/connector_indexers/google_gmail_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/google_gmail_indexer.py
@@ -103,7 +103,7 @@ def _build_connector_doc(
markdown_content: str,
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
"""Map a raw Gmail API message dict to a ConnectorDocument."""
@@ -142,7 +142,7 @@ def _build_connector_doc(
source_markdown=markdown_content,
unique_id=message_id,
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata=metadata,
@@ -152,7 +152,7 @@ def _build_connector_doc(
async def index_google_gmail_messages(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -166,7 +166,7 @@ async def index_google_gmail_messages(
Args:
session: Database session
connector_id: ID of the Gmail connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
start_date: Start date for filtering messages (YYYY-MM-DD format)
end_date: End date for filtering messages (YYYY-MM-DD format)
@@ -177,7 +177,7 @@ async def index_google_gmail_messages(
Returns:
Tuple of (number_of_indexed_messages, number_of_skipped_messages, status_message)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="google_gmail_messages_indexing",
@@ -401,7 +401,7 @@ async def index_google_gmail_messages(
title=_gmail_subject(msg),
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id=msg.get("id", ""),
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -443,7 +443,7 @@ async def index_google_gmail_messages(
message,
markdown_content,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -493,7 +493,7 @@ async def index_google_gmail_messages(
await mark_connector_documents_failed(
session,
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=stuck_placeholders,
)
diff --git a/surfsense_backend/app/tasks/connector_indexers/linear_indexer.py b/surfsense_backend/app/tasks/connector_indexers/linear_indexer.py
index 2bde77f79..5ce85251b 100644
--- a/surfsense_backend/app/tasks/connector_indexers/linear_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/linear_indexer.py
@@ -39,7 +39,7 @@ def _build_connector_doc(
issue_content: str,
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
"""Map a raw Linear issue dict to a ConnectorDocument."""
@@ -67,7 +67,7 @@ def _build_connector_doc(
source_markdown=issue_content,
unique_id=issue_id,
document_type=DocumentType.LINEAR_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata=metadata,
@@ -77,7 +77,7 @@ def _build_connector_doc(
async def index_linear_issues(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -90,7 +90,7 @@ async def index_linear_issues(
Returns:
Tuple of (indexed_count, skipped_count, warning_or_error_message)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="linear_issues_indexing",
@@ -213,7 +213,7 @@ async def index_linear_issues(
title=f"{issue.get('identifier', '')}: {issue.get('title', '')}",
document_type=DocumentType.LINEAR_CONNECTOR,
unique_id=issue.get("id", ""),
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -267,7 +267,7 @@ async def index_linear_issues(
formatted_issue,
issue_content,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -317,7 +317,7 @@ async def index_linear_issues(
await mark_connector_documents_failed(
session,
document_type=DocumentType.LINEAR_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=stuck_placeholders,
)
diff --git a/surfsense_backend/app/tasks/connector_indexers/local_folder_indexer.py b/surfsense_backend/app/tasks/connector_indexers/local_folder_indexer.py
index 2505fa7c4..18c99b4df 100644
--- a/surfsense_backend/app/tasks/connector_indexers/local_folder_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/local_folder_indexer.py
@@ -173,15 +173,15 @@ async def _read_file_content(
return result.markdown_content
-def _content_hash(content: str, search_space_id: int) -> str:
- """SHA-256 hash of content scoped to a search space.
+def _content_hash(content: str, workspace_id: int) -> str:
+ """SHA-256 hash of content scoped to a workspace.
Matches the format used by ``compute_content_hash`` in the unified
pipeline so that dedup checks are consistent.
"""
import hashlib
- return hashlib.sha256(f"{search_space_id}:{content}".encode()).hexdigest()
+ return hashlib.sha256(f"{workspace_id}:{content}".encode()).hexdigest()
def _compute_raw_file_hash(file_path: str) -> str:
@@ -203,7 +203,7 @@ def _compute_raw_file_hash(file_path: str) -> str:
async def _compute_file_content_hash(
file_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
*,
vision_llm=None,
processing_mode: str = "basic",
@@ -215,14 +215,14 @@ async def _compute_file_content_hash(
content = await _read_file_content(
file_path, filename, vision_llm=vision_llm, processing_mode=processing_mode
)
- return content, _content_hash(content, search_space_id)
+ return content, _content_hash(content, workspace_id)
async def _mirror_folder_structure(
session: AsyncSession,
folder_path: str,
folder_name: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
root_folder_id: int | None = None,
exclude_patterns: list[str] | None = None,
@@ -262,7 +262,7 @@ async def _mirror_folder_structure(
if not root_folder_id:
root_folder = Folder(
name=folder_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=user_id,
position="a0",
)
@@ -283,7 +283,7 @@ async def _mirror_folder_structure(
select(Folder).where(
Folder.name == dir_name,
Folder.parent_id == parent_id,
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
)
)
).scalar_one_or_none()
@@ -294,7 +294,7 @@ async def _mirror_folder_structure(
new_folder = Folder(
name=dir_name,
parent_id=parent_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=user_id,
position="a0",
)
@@ -310,7 +310,7 @@ async def _resolve_folder_for_file(
session: AsyncSession,
rel_path: str,
root_folder_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> int:
"""Given a file's relative path, ensure all parent Folder rows exist and
@@ -333,7 +333,7 @@ async def _resolve_folder_for_file(
select(Folder).where(
Folder.name == part,
Folder.parent_id == current_parent_id,
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
)
)
).scalar_one_or_none()
@@ -344,7 +344,7 @@ async def _resolve_folder_for_file(
new_folder = Folder(
name=part,
parent_id=current_parent_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=user_id,
position="a0",
)
@@ -416,7 +416,7 @@ async def _cleanup_empty_folder_chain(
async def _cleanup_empty_folders(
session: AsyncSession,
root_folder_id: int,
- search_space_id: int,
+ workspace_id: int,
existing_dirs_on_disk: set[str],
folder_mapping: dict[str, int],
subtree_ids: list[int] | None = None,
@@ -427,7 +427,7 @@ async def _cleanup_empty_folders(
id_to_rel: dict[int, str] = {fid: rel for rel, fid in folder_mapping.items() if rel}
query = select(Folder).where(
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
Folder.id != root_folder_id,
)
if subtree_ids is not None:
@@ -476,7 +476,7 @@ def _build_connector_doc(
relative_path: str,
folder_name: str,
*,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
"""Build a ConnectorDocument from a local file's extracted content."""
@@ -493,7 +493,7 @@ def _build_connector_doc(
source_markdown=content,
unique_id=unique_id,
document_type=DocumentType.LOCAL_FOLDER_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=None,
created_by_id=user_id,
metadata=metadata,
@@ -502,7 +502,7 @@ def _build_connector_doc(
async def index_local_folder(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_path: str,
folder_name: str,
@@ -521,7 +521,7 @@ async def index_local_folder(
Returns (indexed_count, skipped_count, root_folder_id, error_or_warning_message).
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="local_folder_indexing",
@@ -564,7 +564,7 @@ async def index_local_folder(
if len(target_file_paths) == 1:
indexed, skipped, err = await _index_single_file(
session=session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
folder_path=folder_path,
folder_name=folder_name,
@@ -576,7 +576,7 @@ async def index_local_folder(
return indexed, skipped, root_folder_id, err
indexed, failed, err = await _index_batch_files(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
folder_path=folder_path,
folder_name=folder_name,
@@ -613,7 +613,7 @@ async def index_local_folder(
session=session,
folder_path=folder_path,
folder_name=folder_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
root_folder_id=root_folder_id,
exclude_patterns=exclude_patterns,
@@ -654,7 +654,7 @@ async def index_local_folder(
unique_identifier_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value,
unique_identifier,
- search_space_id,
+ workspace_id,
)
seen_unique_hashes.add(unique_identifier_hash)
@@ -709,7 +709,7 @@ async def index_local_folder(
content, content_hash = await _compute_file_content_hash(
file_path_abs,
file_info["relative_path"],
- search_space_id,
+ workspace_id,
)
except Exception as read_err:
logger.warning(f"Could not read {file_path_abs}: {read_err}")
@@ -745,7 +745,7 @@ async def index_local_folder(
content, content_hash = await _compute_file_content_hash(
file_path_abs,
file_info["relative_path"],
- search_space_id,
+ workspace_id,
)
except Exception as read_err:
logger.warning(f"Could not read {file_path_abs}: {read_err}")
@@ -765,7 +765,7 @@ async def index_local_folder(
content=content,
relative_path=relative_path,
folder_name=folder_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
connector_docs.append(doc)
@@ -789,7 +789,7 @@ async def index_local_folder(
(
await session.execute(
select(Folder.id).where(
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
)
)
)
@@ -803,7 +803,7 @@ async def index_local_folder(
await session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.folder_id.in_(list(all_root_folder_ids)),
)
)
@@ -886,7 +886,7 @@ async def index_local_folder(
await _cleanup_empty_folders(
session,
root_fid,
- search_space_id,
+ workspace_id,
existing_dirs,
folder_mapping,
subtree_ids=subtree_ids,
@@ -943,7 +943,7 @@ BATCH_CONCURRENCY = 5
async def _index_batch_files(
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_path: str,
folder_name: str,
@@ -968,7 +968,7 @@ async def _index_batch_files(
async with semaphore:
try:
async with get_celery_session_maker()() as file_session:
- task_logger = TaskLoggingService(file_session, search_space_id)
+ task_logger = TaskLoggingService(file_session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="local_folder_indexing",
source="local_folder_batch_indexing",
@@ -977,7 +977,7 @@ async def _index_batch_files(
)
ix, _sk, err = await _index_single_file(
session=file_session,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
folder_path=folder_path,
folder_name=folder_name,
@@ -1017,7 +1017,7 @@ async def _index_batch_files(
async def _index_single_file(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_path: str,
folder_name: str,
@@ -1033,7 +1033,7 @@ async def _index_single_file(
rel = str(full_path.relative_to(folder_path))
unique_id = f"{folder_name}:{rel}"
uid_hash = compute_identifier_hash(
- DocumentType.LOCAL_FOLDER_FILE.value, unique_id, search_space_id
+ DocumentType.LOCAL_FOLDER_FILE.value, unique_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, uid_hash)
if existing:
@@ -1052,7 +1052,7 @@ async def _index_single_file(
unique_id = f"{folder_name}:{rel_path}"
uid_hash = compute_identifier_hash(
- DocumentType.LOCAL_FOLDER_FILE.value, unique_id, search_space_id
+ DocumentType.LOCAL_FOLDER_FILE.value, unique_id, workspace_id
)
raw_hash = await asyncio.to_thread(_compute_raw_file_hash, str(full_path))
@@ -1081,7 +1081,7 @@ async def _index_single_file(
try:
content, content_hash = await _compute_file_content_hash(
- str(full_path), full_path.name, search_space_id
+ str(full_path), full_path.name, workspace_id
)
except Exception as e:
return 0, 1, f"Could not read file: {e}"
@@ -1108,13 +1108,13 @@ async def _index_single_file(
content=content,
relative_path=rel_path,
folder_name=folder_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
if root_folder_id:
connector_doc.folder_id = await _resolve_folder_for_file(
- session, rel_path, root_folder_id, search_space_id, user_id
+ session, rel_path, root_folder_id, workspace_id, user_id
)
pipeline = IndexingPipelineService(session)
@@ -1166,7 +1166,7 @@ async def _mirror_folder_structure_from_paths(
session: AsyncSession,
relative_paths: list[str],
folder_name: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
root_folder_id: int | None = None,
) -> tuple[dict[str, int], int]:
@@ -1203,7 +1203,7 @@ async def _mirror_folder_structure_from_paths(
if not root_folder_id:
root_folder = Folder(
name=folder_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=user_id,
position="a0",
)
@@ -1224,7 +1224,7 @@ async def _mirror_folder_structure_from_paths(
select(Folder).where(
Folder.name == dir_name,
Folder.parent_id == parent_id,
- Folder.search_space_id == search_space_id,
+ Folder.workspace_id == workspace_id,
)
)
).scalar_one_or_none()
@@ -1235,7 +1235,7 @@ async def _mirror_folder_structure_from_paths(
new_folder = Folder(
name=dir_name,
parent_id=parent_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=user_id,
position="a0",
)
@@ -1252,7 +1252,7 @@ UPLOAD_BATCH_CONCURRENCY = 5
async def index_uploaded_files(
session: AsyncSession,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_name: str,
root_folder_id: int,
@@ -1274,7 +1274,7 @@ async def index_uploaded_files(
mode = ProcessingMode.coerce(processing_mode)
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="local_folder_indexing",
source="uploaded_folder_indexing",
@@ -1288,7 +1288,7 @@ async def index_uploaded_files(
session=session,
relative_paths=all_relative_paths,
folder_name=folder_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
root_folder_id=root_folder_id,
)
@@ -1303,7 +1303,7 @@ async def index_uploaded_files(
if use_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm_instance = await get_vision_llm(session, search_space_id)
+ vision_llm_instance = await get_vision_llm(session, workspace_id)
indexed_count = 0
failed_count = 0
@@ -1319,7 +1319,7 @@ async def index_uploaded_files(
uid_hash = compute_identifier_hash(
DocumentType.LOCAL_FOLDER_FILE.value,
unique_id,
- search_space_id,
+ workspace_id,
)
raw_hash = await asyncio.to_thread(_compute_raw_file_hash, temp_path)
@@ -1357,7 +1357,7 @@ async def index_uploaded_files(
content, content_hash = await _compute_file_content_hash(
temp_path,
filename,
- search_space_id,
+ workspace_id,
vision_llm=vision_llm_instance,
processing_mode=mode.value,
)
@@ -1391,7 +1391,7 @@ async def index_uploaded_files(
content=content,
relative_path=relative_path,
folder_name=folder_name,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -1399,7 +1399,7 @@ async def index_uploaded_files(
session,
relative_path,
root_folder_id,
- search_space_id,
+ workspace_id,
user_id,
)
diff --git a/surfsense_backend/app/tasks/connector_indexers/luma_indexer.py b/surfsense_backend/app/tasks/connector_indexers/luma_indexer.py
index eab2c9793..79a81f319 100644
--- a/surfsense_backend/app/tasks/connector_indexers/luma_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/luma_indexer.py
@@ -45,7 +45,7 @@ HEARTBEAT_INTERVAL_SECONDS = 30
async def index_luma_events(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -58,7 +58,7 @@ async def index_luma_events(
Args:
session: Database session
connector_id: ID of the Luma connector
- search_space_id: ID of the search space to store documents in
+ workspace_id: ID of the workspace to store documents in
user_id: User ID
start_date: Start date for indexing (YYYY-MM-DD format). Can be in the past or future.
end_date: End date for indexing (YYYY-MM-DD format). Can be in the future to index upcoming events.
@@ -69,7 +69,7 @@ async def index_luma_events(
Returns:
Tuple containing (number of documents indexed, error message or None)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -290,11 +290,11 @@ async def index_luma_events(
# Generate unique identifier hash for this Luma event
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.LUMA_CONNECTOR, event_id, search_space_id
+ DocumentType.LUMA_CONNECTOR, event_id, workspace_id
)
# Generate content hash
- content_hash = generate_content_hash(event_markdown, search_space_id)
+ content_hash = generate_content_hash(event_markdown, workspace_id)
# Check if document with this unique identifier already exists
existing_document = await check_document_by_unique_identifier(
@@ -355,7 +355,7 @@ async def index_luma_events(
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=event_name,
document_type=DocumentType.LUMA_CONNECTOR,
document_metadata={
diff --git a/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py b/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py
index 9ebafbcdb..21e67af73 100644
--- a/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py
@@ -41,7 +41,7 @@ def _build_connector_doc(
markdown_content: str,
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
"""Map a raw Notion page dict to a ConnectorDocument."""
@@ -61,7 +61,7 @@ def _build_connector_doc(
source_markdown=markdown_content,
unique_id=page_id,
document_type=DocumentType.NOTION_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata=metadata,
@@ -71,7 +71,7 @@ def _build_connector_doc(
async def index_notion_pages(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -85,7 +85,7 @@ async def index_notion_pages(
Returns:
Tuple of (indexed_count, skipped_count, warning_or_error_message)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="notion_pages_indexing",
@@ -255,7 +255,7 @@ async def index_notion_pages(
title=page.get("title", f"Untitled page ({page.get('page_id', '')})"),
document_type=DocumentType.NOTION_CONNECTOR,
unique_id=page.get("page_id", ""),
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -307,7 +307,7 @@ async def index_notion_pages(
page,
markdown_content,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
@@ -357,7 +357,7 @@ async def index_notion_pages(
await mark_connector_documents_failed(
session,
document_type=DocumentType.NOTION_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=stuck_placeholders,
)
diff --git a/surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py b/surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py
index 1a83551fb..395b98746 100644
--- a/surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py
@@ -51,7 +51,7 @@ logger = logging.getLogger(__name__)
async def _should_skip_file(
session: AsyncSession,
file: dict,
- search_space_id: int,
+ workspace_id: int,
) -> tuple[bool, str | None]:
"""Pre-filter: detect unchanged / rename-only files."""
file_id = file.get("id")
@@ -66,14 +66,14 @@ async def _should_skip_file(
return True, "missing file_id"
primary_hash = compute_identifier_hash(
- DocumentType.ONEDRIVE_FILE.value, file_id, search_space_id
+ DocumentType.ONEDRIVE_FILE.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, primary_hash)
if not existing:
result = await session.execute(
select(Document).where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.ONEDRIVE_FILE,
cast(Document.document_metadata["onedrive_file_id"], String) == file_id,
)
@@ -137,7 +137,7 @@ def _build_connector_doc(
onedrive_metadata: dict,
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> ConnectorDocument:
file_id = file.get("id", "")
@@ -155,7 +155,7 @@ def _build_connector_doc(
source_markdown=markdown,
unique_id=file_id,
document_type=DocumentType.ONEDRIVE_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata=metadata,
@@ -167,7 +167,7 @@ async def _download_files_parallel(
files: list[dict],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
max_concurrency: int = 3,
on_heartbeat: HeartbeatCallbackType | None = None,
@@ -201,7 +201,7 @@ async def _download_files_parallel(
markdown,
od_metadata,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
async with hb_lock:
@@ -246,7 +246,7 @@ async def _download_and_index(
files: list[dict],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
on_heartbeat: HeartbeatCallbackType | None = None,
vision_llm=None,
@@ -256,7 +256,7 @@ async def _download_and_index(
onedrive_client,
files,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat,
vision_llm=vision_llm,
@@ -267,7 +267,7 @@ async def _download_and_index(
await mark_connector_documents_failed(
session,
document_type=DocumentType.ONEDRIVE_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
failures=failed_files,
)
@@ -284,17 +284,17 @@ async def _download_and_index(
return batch_indexed, len(failed_files) + batch_failed
-async def _remove_document(session: AsyncSession, file_id: str, search_space_id: int):
+async def _remove_document(session: AsyncSession, file_id: str, workspace_id: int):
"""Remove a document that was deleted in OneDrive."""
primary_hash = compute_identifier_hash(
- DocumentType.ONEDRIVE_FILE.value, file_id, search_space_id
+ DocumentType.ONEDRIVE_FILE.value, file_id, workspace_id
)
existing = await check_document_by_unique_identifier(session, primary_hash)
if not existing:
result = await session.execute(
select(Document).where(
- Document.search_space_id == search_space_id,
+ Document.workspace_id == workspace_id,
Document.document_type == DocumentType.ONEDRIVE_FILE,
cast(Document.document_metadata["onedrive_file_id"], String) == file_id,
)
@@ -312,7 +312,7 @@ async def _index_selected_files(
file_ids: list[tuple[str, str | None]],
*,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
on_heartbeat: HeartbeatCallbackType | None = None,
vision_llm=None,
@@ -335,7 +335,7 @@ async def _index_selected_files(
errors.append(f"File '{display}': {error or 'File not found'}")
continue
- skip, msg = await _should_skip_file(session, file, search_space_id)
+ skip, msg = await _should_skip_file(session, file, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -365,7 +365,7 @@ async def _index_selected_files(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat,
vision_llm=vision_llm,
@@ -389,7 +389,7 @@ async def _index_full_scan(
onedrive_client: OneDriveClient,
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_id: str,
folder_name: str,
@@ -438,7 +438,7 @@ async def _index_full_scan(
raise Exception(f"Failed to list OneDrive files: {error}")
for file in all_files[:max_files]:
- skip, msg = await _should_skip_file(session, file, search_space_id)
+ skip, msg = await _should_skip_file(session, file, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -473,7 +473,7 @@ async def _index_full_scan(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat_callback,
vision_llm=vision_llm,
@@ -497,7 +497,7 @@ async def _index_with_delta_sync(
onedrive_client: OneDriveClient,
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
folder_id: str | None,
delta_link: str,
@@ -553,7 +553,7 @@ async def _index_with_delta_sync(
if change.get("deleted"):
fid = change.get("id")
if fid:
- await _remove_document(session, fid, search_space_id)
+ await _remove_document(session, fid, workspace_id)
continue
if "folder" in change:
@@ -562,7 +562,7 @@ async def _index_with_delta_sync(
if not change.get("file"):
continue
- skip, msg = await _should_skip_file(session, change, search_space_id)
+ skip, msg = await _should_skip_file(session, change, workspace_id)
if skip:
if msg and msg.startswith("unsupported:"):
unsupported_count += 1
@@ -597,7 +597,7 @@ async def _index_with_delta_sync(
session,
files_to_download,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
on_heartbeat=on_heartbeat_callback,
vision_llm=vision_llm,
@@ -625,7 +625,7 @@ async def _index_with_delta_sync(
async def index_onedrive_files(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
items_dict: dict,
) -> tuple[int, int, str | None, int]:
@@ -638,7 +638,7 @@ async def index_onedrive_files(
"indexing_options": {"max_files": 500, "include_subfolders": true, "use_delta_sync": true}
}
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
log_entry = await task_logger.log_task_start(
task_name="onedrive_files_indexing",
source="connector_indexing_task",
@@ -673,7 +673,7 @@ async def index_onedrive_files(
if connector_enable_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm = await get_vision_llm(session, search_space_id)
+ vision_llm = await get_vision_llm(session, workspace_id)
onedrive_client = OneDriveClient(session, connector_id)
@@ -695,7 +695,7 @@ async def index_onedrive_files(
session,
file_tuples,
connector_id=connector_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
vision_llm=vision_llm,
)
@@ -719,7 +719,7 @@ async def index_onedrive_files(
onedrive_client,
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
folder_id,
delta_link,
@@ -744,7 +744,7 @@ async def index_onedrive_files(
onedrive_client,
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
folder_id,
folder_name,
@@ -763,7 +763,7 @@ async def index_onedrive_files(
onedrive_client,
session,
connector_id,
- search_space_id,
+ workspace_id,
user_id,
folder_id,
folder_name,
diff --git a/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py b/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py
index ac63af38c..6b19bf7c4 100644
--- a/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py
@@ -116,7 +116,7 @@ def _build_batch_document_string(
async def index_slack_messages(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -139,7 +139,7 @@ async def index_slack_messages(
Args:
session: Database session
connector_id: ID of the Slack connector
- search_space_id: ID of the search space to store documents in
+ workspace_id: ID of the workspace to store documents in
user_id: ID of the user
start_date: Start date for indexing (YYYY-MM-DD format)
end_date: End date for indexing (YYYY-MM-DD format)
@@ -150,7 +150,7 @@ async def index_slack_messages(
Returns:
Tuple containing (number of documents indexed, error message or None)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -380,12 +380,12 @@ async def index_slack_messages(
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.SLACK_CONNECTOR,
unique_identifier,
- search_space_id,
+ workspace_id,
)
# Generate content hash
content_hash = generate_content_hash(
- combined_document_string, search_space_id
+ combined_document_string, workspace_id
)
# Check if document with this unique identifier already exists
@@ -449,7 +449,7 @@ async def index_slack_messages(
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=f"{team_name}#{channel_name}",
document_type=DocumentType.SLACK_CONNECTOR,
document_metadata={
diff --git a/surfsense_backend/app/tasks/connector_indexers/teams_indexer.py b/surfsense_backend/app/tasks/connector_indexers/teams_indexer.py
index e48aedaa5..90dc50fc5 100644
--- a/surfsense_backend/app/tasks/connector_indexers/teams_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/teams_indexer.py
@@ -116,7 +116,7 @@ def _build_batch_document_string(
async def index_teams_messages(
session: AsyncSession,
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
@@ -139,7 +139,7 @@ async def index_teams_messages(
Args:
session: Database session
connector_id: ID of the Teams connector
- search_space_id: ID of the search space to store documents in
+ workspace_id: ID of the workspace to store documents in
user_id: ID of the user
start_date: Start date for indexing (YYYY-MM-DD format)
end_date: End date for indexing (YYYY-MM-DD format)
@@ -150,7 +150,7 @@ async def index_teams_messages(
Returns:
Tuple containing (number of documents indexed, error message or None)
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -398,12 +398,12 @@ async def index_teams_messages(
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.TEAMS_CONNECTOR,
unique_identifier,
- search_space_id,
+ workspace_id,
)
# Generate content hash
content_hash = generate_content_hash(
- combined_document_string, search_space_id
+ combined_document_string, workspace_id
)
# Check if document with this unique identifier already exists
@@ -475,7 +475,7 @@ async def index_teams_messages(
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=f"{team_name} - {channel_name}",
document_type=DocumentType.TEAMS_CONNECTOR,
document_metadata={
diff --git a/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py b/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py
deleted file mode 100644
index d81de67c0..000000000
--- a/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py
+++ /dev/null
@@ -1,529 +0,0 @@
-"""
-Webcrawler connector indexer.
-
-Implements 2-phase document status updates for real-time UI feedback:
-- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
-- Phase 2: Process each document: pending → processing → ready/failed
-"""
-
-import time
-from collections.abc import Awaitable, Callable
-from datetime import datetime
-
-from sqlalchemy.exc import SQLAlchemyError
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.connectors.webcrawler_connector import WebCrawlerConnector
-from app.db import Document, DocumentStatus, DocumentType, SearchSourceConnectorType
-from app.services.task_logging_service import TaskLoggingService
-from app.utils.document_converters import (
- create_document_chunks,
- embed_text,
- generate_content_hash,
- generate_unique_identifier_hash,
-)
-from app.utils.webcrawler_utils import parse_webcrawler_urls
-
-from .base import (
- check_document_by_unique_identifier,
- check_duplicate_document_by_hash,
- get_connector_by_id,
- get_current_timestamp,
- logger,
- safe_set_chunks,
- update_connector_last_indexed,
-)
-
-# Type hint for heartbeat callback
-HeartbeatCallbackType = Callable[[int], Awaitable[None]]
-
-# Heartbeat interval in seconds
-HEARTBEAT_INTERVAL_SECONDS = 30
-
-
-async def index_crawled_urls(
- session: AsyncSession,
- connector_id: int,
- search_space_id: int,
- user_id: str,
- start_date: str | None = None,
- end_date: str | None = None,
- update_last_indexed: bool = True,
- on_heartbeat_callback: HeartbeatCallbackType | None = None,
-) -> tuple[int, str | None]:
- """
- Index web page URLs with real-time document status updates.
-
- Implements 2-phase approach for real-time UI feedback:
- - Phase 1: Create all documents with 'pending' status (visible in UI immediately)
- - Phase 2: Process each document: pending → processing → ready/failed
-
- Args:
- session: Database session
- connector_id: ID of the webcrawler connector
- search_space_id: ID of the search space to store documents in
- user_id: User ID
- start_date: Start date for filtering (YYYY-MM-DD format) - optional
- end_date: End date for filtering (YYYY-MM-DD format) - optional
- update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
- on_heartbeat_callback: Optional callback to update notification during long-running indexing.
-
- Returns:
- Tuple containing (number of documents indexed, error message or None)
- """
- task_logger = TaskLoggingService(session, search_space_id)
-
- # Log task start
- log_entry = await task_logger.log_task_start(
- task_name="crawled_url_indexing",
- source="connector_indexing_task",
- message=f"Starting web page URL indexing for connector {connector_id}",
- metadata={
- "connector_id": connector_id,
- "user_id": str(user_id),
- "start_date": start_date,
- "end_date": end_date,
- },
- )
-
- try:
- # Get the connector
- await task_logger.log_task_progress(
- log_entry,
- f"Retrieving webcrawler connector {connector_id} from database",
- {"stage": "connector_retrieval"},
- )
-
- # Get the connector from the database
- connector = await get_connector_by_id(
- session, connector_id, SearchSourceConnectorType.WEBCRAWLER_CONNECTOR
- )
-
- if not connector:
- await task_logger.log_task_failure(
- log_entry,
- f"Connector with ID {connector_id} not found or is not a webcrawler connector",
- "Connector not found",
- {"error_type": "ConnectorNotFound"},
- )
- return (
- 0,
- f"Connector with ID {connector_id} not found or is not a webcrawler connector",
- )
-
- # Get the Firecrawl API key from the connector config (optional)
- api_key = connector.config.get("FIRECRAWL_API_KEY")
-
- # Get URLs from connector config
- raw_initial_urls = connector.config.get("INITIAL_URLS")
- urls = parse_webcrawler_urls(raw_initial_urls)
-
- # DEBUG: Log connector config details for troubleshooting empty URL issues
- logger.info(
- f"Starting crawled web page indexing for connector {connector_id} with {len(urls)} URLs. "
- f"Connector name: {connector.name}, "
- f"INITIAL_URLS type: {type(raw_initial_urls).__name__}, "
- f"INITIAL_URLS value: {repr(raw_initial_urls)[:200] if raw_initial_urls else 'None'}"
- )
-
- # Initialize webcrawler client
- await task_logger.log_task_progress(
- log_entry,
- f"Initializing webcrawler client for connector {connector_id}",
- {
- "stage": "client_initialization",
- "use_firecrawl": bool(api_key),
- },
- )
-
- crawler = WebCrawlerConnector(firecrawl_api_key=api_key)
-
- # Validate URLs
- if not urls:
- # DEBUG: Log detailed connector config for troubleshooting
- logger.error(
- f"No URLs provided for indexing. Connector ID: {connector_id}, "
- f"Connector name: {connector.name}, "
- f"Config keys: {list(connector.config.keys()) if connector.config else 'None'}, "
- f"INITIAL_URLS raw value: {raw_initial_urls!r}"
- )
- await task_logger.log_task_failure(
- log_entry,
- "No URLs provided for indexing",
- f"Empty URL list. INITIAL_URLS value: {repr(raw_initial_urls)[:100]}",
- {"error_type": "ValidationError", "connector_name": connector.name},
- )
- return 0, "No URLs provided for indexing"
-
- await task_logger.log_task_progress(
- log_entry,
- f"Starting to process {len(urls)} URLs",
- {
- "stage": "processing",
- "total_urls": len(urls),
- },
- )
-
- documents_indexed = 0
- documents_updated = 0
- documents_skipped = 0
- documents_failed = 0
- duplicate_content_count = 0
-
- # Heartbeat tracking - update notification periodically to prevent appearing stuck
- last_heartbeat_time = time.time()
-
- # =======================================================================
- # PHASE 1: Analyze all URLs, create pending documents for new ones
- # This makes ALL new documents visible in the UI immediately with pending status
- # =======================================================================
- urls_to_process = [] # List of dicts with document and URL data
- new_documents_created = False
-
- for url in urls:
- try:
- # Generate unique identifier hash for this URL
- unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.CRAWLED_URL, url, search_space_id
- )
-
- # Check if document with this unique identifier already exists
- existing_document = await check_document_by_unique_identifier(
- session, unique_identifier_hash
- )
-
- if existing_document:
- # Document exists - check if it's already being processed
- if DocumentStatus.is_state(
- existing_document.status, DocumentStatus.PENDING
- ):
- logger.info(f"URL {url} already pending. Skipping.")
- documents_skipped += 1
- continue
- if DocumentStatus.is_state(
- existing_document.status, DocumentStatus.PROCESSING
- ):
- logger.info(f"URL {url} already processing. Skipping.")
- documents_skipped += 1
- continue
-
- # Queue existing document for potential update check
- urls_to_process.append(
- {
- "document": existing_document,
- "is_new": False,
- "url": url,
- "unique_identifier_hash": unique_identifier_hash,
- }
- )
- continue
-
- # Create new document with PENDING status (visible in UI immediately)
- document = Document(
- search_space_id=search_space_id,
- title=url[:100], # Placeholder - URL as title (truncated)
- document_type=DocumentType.CRAWLED_URL,
- document_metadata={
- "url": url,
- "connector_id": connector_id,
- },
- content="Pending crawl...", # Placeholder content
- content_hash=unique_identifier_hash, # Temporary unique value
- unique_identifier_hash=unique_identifier_hash,
- embedding=None,
- chunks=[], # Empty at creation - safe for async
- status=DocumentStatus.pending(), # PENDING status - visible in UI
- updated_at=get_current_timestamp(),
- created_by_id=user_id,
- connector_id=connector_id,
- )
- session.add(document)
- new_documents_created = True
-
- urls_to_process.append(
- {
- "document": document,
- "is_new": True,
- "url": url,
- "unique_identifier_hash": unique_identifier_hash,
- }
- )
-
- except Exception as e:
- logger.error(f"Error in Phase 1 for URL {url}: {e!s}", exc_info=True)
- documents_failed += 1
- continue
-
- # Commit all pending documents - they all appear in UI now
- if new_documents_created:
- logger.info(
- f"Phase 1: Committing {len([u for u in urls_to_process if u['is_new']])} pending documents"
- )
- await session.commit()
-
- # =======================================================================
- # PHASE 2: Process each URL one by one
- # Each document transitions: pending → processing → ready/failed
- # =======================================================================
- logger.info(f"Phase 2: Processing {len(urls_to_process)} URLs")
-
- for item in urls_to_process:
- # Send heartbeat periodically
- if on_heartbeat_callback:
- current_time = time.time()
- if current_time - last_heartbeat_time >= HEARTBEAT_INTERVAL_SECONDS:
- await on_heartbeat_callback(documents_indexed + documents_updated)
- last_heartbeat_time = current_time
-
- document = item["document"]
- url = item["url"]
- is_new = item["is_new"]
-
- try:
- # Set to PROCESSING and commit - shows "processing" in UI for THIS document only
- document.status = DocumentStatus.processing()
- await session.commit()
-
- await task_logger.log_task_progress(
- log_entry,
- f"Crawling URL: {url}",
- {
- "stage": "crawling_url",
- "url": url,
- },
- )
-
- # Crawl the URL
- crawl_result, error = await crawler.crawl_url(url)
-
- if error or not crawl_result:
- logger.warning(f"Failed to crawl URL {url}: {error}")
- document.status = DocumentStatus.failed(error or "Crawl failed")
- document.updated_at = get_current_timestamp()
- await session.commit()
- documents_failed += 1
- continue
-
- # Extract content and metadata
- content = crawl_result.get("content", "")
- metadata = crawl_result.get("metadata", {})
- crawler_type = crawl_result.get("crawler_type", "unknown")
-
- if not content.strip():
- logger.warning(f"Skipping URL with no content: {url}")
- document.status = DocumentStatus.failed("No content extracted")
- document.updated_at = get_current_timestamp()
- await session.commit()
- documents_failed += 1
- continue
-
- # Format content as structured document for summary generation
- crawler.format_to_structured_document(crawl_result)
-
- # Generate content hash using a version WITHOUT metadata
- structured_document_for_hash = crawler.format_to_structured_document(
- crawl_result, exclude_metadata=True
- )
- content_hash = generate_content_hash(
- structured_document_for_hash, search_space_id
- )
-
- # Extract useful metadata
- title = metadata.get("title", url)
- metadata.get("description", "")
- metadata.get("language", "")
-
- # Update title immediately for better UX
- document.title = title
- await session.commit()
-
- # For existing documents, check if content has changed
- if not is_new and document.content_hash == content_hash:
- logger.info(f"Document for URL {url} unchanged. Marking as ready.")
- # Ensure status is ready (might have been stuck)
- document.status = DocumentStatus.ready()
- await session.commit()
- documents_skipped += 1
- continue
-
- # For new documents, check if duplicate content exists elsewhere
- if is_new:
- with session.no_autoflush:
- duplicate_by_content = await check_duplicate_document_by_hash(
- session, content_hash
- )
-
- if duplicate_by_content:
- logger.info(
- f"URL {url} already indexed by another connector "
- f"(existing document ID: {duplicate_by_content.id}). "
- f"Marking as failed."
- )
- document.status = DocumentStatus.failed(
- "Duplicate content exists"
- )
- document.updated_at = get_current_timestamp()
- await session.commit()
- duplicate_content_count += 1
- documents_skipped += 1
- continue
-
- # Select deterministic document content
-
- summary_content = f"Crawled URL: {title}\n\nURL: {url}\n\n{content}"
- summary_embedding = embed_text(summary_content)
-
- # Process chunks
- chunks = await create_document_chunks(content)
-
- # Update document to READY with actual content
- document.title = title
- document.content = summary_content
- document.content_hash = content_hash
- document.embedding = summary_embedding
- document.document_metadata = {
- **metadata,
- "crawler_type": crawler_type,
- "indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
- "connector_id": connector_id,
- }
- await safe_set_chunks(session, document, chunks)
- document.status = DocumentStatus.ready() # READY status
- document.updated_at = get_current_timestamp()
-
- if is_new:
- documents_indexed += 1
- else:
- documents_updated += 1
-
- logger.info(f"Successfully processed URL {url}")
-
- # Batch commit every 10 documents (for ready status updates)
- if (documents_indexed + documents_updated) % 10 == 0:
- logger.info(
- f"Committing batch: {documents_indexed + documents_updated} URLs processed so far"
- )
- await session.commit()
-
- except Exception as e:
- logger.error(f"Error processing URL {url}: {e!s}", exc_info=True)
- # Mark document as failed with reason (visible in UI)
- try:
- document.status = DocumentStatus.failed(str(e)[:200])
- document.updated_at = get_current_timestamp()
- await session.commit()
- except Exception as status_error:
- logger.error(
- f"Failed to update document status to failed: {status_error}"
- )
- documents_failed += 1
- continue
-
- total_processed = documents_indexed + documents_updated
-
- # CRITICAL: Always update timestamp (even if 0 documents indexed) so Zero syncs
- await update_connector_last_indexed(session, connector, update_last_indexed)
-
- # Final commit for any remaining documents not yet committed in batches
- logger.info(
- f"Final commit: Total {documents_indexed} new, {documents_updated} updated URLs processed"
- )
- try:
- await session.commit()
- logger.info(
- "Successfully committed all webcrawler document changes to database"
- )
- except Exception as e:
- # Handle any remaining integrity errors gracefully
- if "duplicate key value violates unique constraint" in str(e).lower():
- logger.warning(
- f"Duplicate content_hash detected during final commit. "
- f"Rolling back and continuing. Error: {e!s}"
- )
- await session.rollback()
- else:
- raise
-
- # Build warning message if there were issues
- warning_parts = []
- if duplicate_content_count > 0:
- warning_parts.append(f"{duplicate_content_count} duplicate")
- if documents_failed > 0:
- warning_parts.append(f"{documents_failed} failed")
- warning_message = ", ".join(warning_parts) if warning_parts else None
-
- await task_logger.log_task_success(
- log_entry,
- f"Successfully completed crawled web page indexing for connector {connector_id}",
- {
- "urls_processed": total_processed,
- "documents_indexed": documents_indexed,
- "documents_updated": documents_updated,
- "documents_skipped": documents_skipped,
- "documents_failed": documents_failed,
- "duplicate_content_count": duplicate_content_count,
- },
- )
-
- logger.info(
- f"Web page indexing completed: {documents_indexed} new, "
- f"{documents_updated} updated, {documents_skipped} skipped, "
- f"{documents_failed} failed"
- )
-
- if warning_message:
- return total_processed, f"Completed with issues: {warning_message}"
-
- return total_processed, None
-
- except SQLAlchemyError as db_error:
- await session.rollback()
- await task_logger.log_task_failure(
- log_entry,
- f"Database error during web page indexing for connector {connector_id}",
- str(db_error),
- {"error_type": "SQLAlchemyError"},
- )
- logger.error(f"Database error: {db_error!s}", exc_info=True)
- return 0, f"Database error: {db_error!s}"
- except Exception as e:
- await session.rollback()
- await task_logger.log_task_failure(
- log_entry,
- f"Failed to index web page URLs for connector {connector_id}",
- str(e),
- {"error_type": type(e).__name__},
- )
- logger.error(f"Failed to index web page URLs: {e!s}", exc_info=True)
- return 0, f"Failed to index web page URLs: {e!s}"
-
-
-async def get_crawled_url_documents(
- session: AsyncSession,
- search_space_id: int,
- connector_id: int | None = None,
-) -> list[Document]:
- """
- Get all crawled URL documents for a search space.
-
- Args:
- session: Database session
- search_space_id: ID of the search space
- connector_id: Optional connector ID to filter by
-
- Returns:
- List of Document objects
- """
- from sqlalchemy import select
-
- query = select(Document).filter(
- Document.search_space_id == search_space_id,
- Document.document_type == DocumentType.CRAWLED_URL,
- )
-
- if connector_id:
- query = query.filter(Document.connector_id == connector_id)
-
- result = await session.execute(query)
- documents = result.scalars().all()
- return list(documents)
diff --git a/surfsense_backend/app/tasks/document_processors/__init__.py b/surfsense_backend/app/tasks/document_processors/__init__.py
index f82c10883..90fd69269 100644
--- a/surfsense_backend/app/tasks/document_processors/__init__.py
+++ b/surfsense_backend/app/tasks/document_processors/__init__.py
@@ -3,15 +3,13 @@ Document processors module for background tasks.
Content extraction is handled by ``app.etl_pipeline.EtlPipelineService``.
This package keeps orchestration (save, notify, page-limit) and
-non-ETL processors (extension, markdown, youtube).
+non-ETL processors (extension, markdown).
"""
from .extension_processor import add_extension_received_document
from .markdown_processor import add_received_markdown_file_document
-from .youtube_processor import add_youtube_video_document
__all__ = [
"add_extension_received_document",
"add_received_markdown_file_document",
- "add_youtube_video_document",
]
diff --git a/surfsense_backend/app/tasks/document_processors/_helpers.py b/surfsense_backend/app/tasks/document_processors/_helpers.py
index 9cd7b87c9..9db655328 100644
--- a/surfsense_backend/app/tasks/document_processors/_helpers.py
+++ b/surfsense_backend/app/tasks/document_processors/_helpers.py
@@ -24,7 +24,7 @@ from .base import (
def get_google_drive_unique_identifier(
connector: dict | None,
filename: str,
- search_space_id: int,
+ workspace_id: int,
) -> tuple[str, str | None]:
"""
Get unique identifier hash, using file_id for Google Drive (stable across renames).
@@ -40,15 +40,15 @@ def get_google_drive_unique_identifier(
if file_id:
primary_hash = generate_unique_identifier_hash(
- DocumentType.GOOGLE_DRIVE_FILE, file_id, search_space_id
+ DocumentType.GOOGLE_DRIVE_FILE, file_id, workspace_id
)
legacy_hash = generate_unique_identifier_hash(
- DocumentType.GOOGLE_DRIVE_FILE, filename, search_space_id
+ DocumentType.GOOGLE_DRIVE_FILE, filename, workspace_id
)
return primary_hash, legacy_hash
primary_hash = generate_unique_identifier_hash(
- DocumentType.FILE, filename, search_space_id
+ DocumentType.FILE, filename, workspace_id
)
return primary_hash, None
diff --git a/surfsense_backend/app/tasks/document_processors/_save.py b/surfsense_backend/app/tasks/document_processors/_save.py
index 9456e2f1c..2509a70ef 100644
--- a/surfsense_backend/app/tasks/document_processors/_save.py
+++ b/surfsense_backend/app/tasks/document_processors/_save.py
@@ -29,7 +29,7 @@ async def save_file_document(
session: AsyncSession,
file_name: str,
markdown_content: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
etl_service: str,
connector: dict | None = None,
@@ -44,7 +44,7 @@ async def save_file_document(
session: Database session
file_name: Name of the processed file
markdown_content: Markdown content to store
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
etl_service: Name of the ETL service (UNSTRUCTURED, LLAMACLOUD, DOCLING)
connector: Optional connector info for Google Drive files
@@ -54,9 +54,9 @@ async def save_file_document(
"""
try:
primary_hash, legacy_hash = get_google_drive_unique_identifier(
- connector, file_name, search_space_id
+ connector, file_name, workspace_id
)
- content_hash = generate_content_hash(markdown_content, search_space_id)
+ content_hash = generate_content_hash(markdown_content, workspace_id)
existing_document = await find_existing_document_with_migration(
session, primary_hash, legacy_hash, content_hash
@@ -102,7 +102,7 @@ async def save_file_document(
doc_type = DocumentType.GOOGLE_DRIVE_FILE
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=file_name,
document_type=doc_type,
document_metadata=doc_metadata,
diff --git a/surfsense_backend/app/tasks/document_processors/circleback_processor.py b/surfsense_backend/app/tasks/document_processors/circleback_processor.py
index ee36d5bc2..413ab9d3d 100644
--- a/surfsense_backend/app/tasks/document_processors/circleback_processor.py
+++ b/surfsense_backend/app/tasks/document_processors/circleback_processor.py
@@ -23,7 +23,7 @@ from app.db import (
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
- SearchSpace,
+ Workspace,
)
from app.utils.document_converters import (
create_document_chunks,
@@ -47,7 +47,7 @@ async def add_circleback_meeting_document(
meeting_name: str,
markdown_content: str,
metadata: dict[str, Any],
- search_space_id: int,
+ workspace_id: int,
connector_id: int | None = None,
) -> Document | None:
"""
@@ -64,7 +64,7 @@ async def add_circleback_meeting_document(
meeting_name: Name of the meeting
markdown_content: Meeting content formatted as markdown
metadata: Meeting metadata dictionary
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
connector_id: ID of the Circleback connector (for deletion support)
Returns:
@@ -75,11 +75,11 @@ async def add_circleback_meeting_document(
# Generate unique identifier hash using Circleback meeting ID
unique_identifier = f"circleback_{meeting_id}"
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.CIRCLEBACK, unique_identifier, search_space_id
+ DocumentType.CIRCLEBACK, unique_identifier, workspace_id
)
# Generate content hash
- content_hash = generate_content_hash(markdown_content, search_space_id)
+ content_hash = generate_content_hash(markdown_content, workspace_id)
# Check if document with this unique identifier already exists
existing_document = await check_document_by_unique_identifier(
@@ -113,13 +113,13 @@ async def add_circleback_meeting_document(
# =======================================================================
# Fetch the user who set up the Circleback connector (preferred)
- # or fall back to search space owner if no connector found
+ # or fall back to workspace owner if no connector found
created_by_user_id = None
- # Try to find the Circleback connector for this search space
+ # Try to find the Circleback connector for this workspace
connector_result = await session.execute(
select(SearchSourceConnector.user_id).where(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CIRCLEBACK_CONNECTOR,
)
@@ -130,15 +130,15 @@ async def add_circleback_meeting_document(
# Use the user who set up the Circleback connector
created_by_user_id = connector_user
else:
- # Fallback: use search space owner if no connector found
- search_space_result = await session.execute(
- select(SearchSpace.user_id).where(SearchSpace.id == search_space_id)
+ # Fallback: use workspace owner if no connector found
+ workspace_result = await session.execute(
+ select(Workspace.user_id).where(Workspace.id == workspace_id)
)
- created_by_user_id = search_space_result.scalar_one_or_none()
+ created_by_user_id = workspace_result.scalar_one_or_none()
# Create new document with PENDING status (visible in UI immediately)
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=meeting_name,
document_type=DocumentType.CIRCLEBACK,
document_metadata={
@@ -162,7 +162,7 @@ async def add_circleback_meeting_document(
# Commit immediately so document appears in UI with pending status
await session.commit()
logger.info(
- f"Created pending Circleback meeting document {meeting_id} in search space {search_space_id}"
+ f"Created pending Circleback meeting document {meeting_id} in workspace {workspace_id}"
)
# =======================================================================
@@ -213,11 +213,11 @@ async def add_circleback_meeting_document(
if existing_document:
logger.info(
- f"Updated Circleback meeting document {meeting_id} in search space {search_space_id}"
+ f"Updated Circleback meeting document {meeting_id} in workspace {workspace_id}"
)
else:
logger.info(
- f"Processed Circleback meeting document {meeting_id} in search space {search_space_id} - now ready"
+ f"Processed Circleback meeting document {meeting_id} in workspace {workspace_id} - now ready"
)
return document
diff --git a/surfsense_backend/app/tasks/document_processors/extension_processor.py b/surfsense_backend/app/tasks/document_processors/extension_processor.py
index bdbc985fa..fbf55d484 100644
--- a/surfsense_backend/app/tasks/document_processors/extension_processor.py
+++ b/surfsense_backend/app/tasks/document_processors/extension_processor.py
@@ -27,7 +27,7 @@ from .base import (
async def add_extension_received_document(
session: AsyncSession,
content: ExtensionDocumentContent,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
) -> Document | None:
"""
@@ -36,13 +36,13 @@ async def add_extension_received_document(
Args:
session: Database session
content: Document content from extension
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
Returns:
Document object if successful, None if failed
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -72,7 +72,7 @@ async def add_extension_received_document(
),
(
"CONTENT",
- ["FORMAT: markdown", "TEXT_START", content.pageContent, "TEXT_END"],
+ ["FORMAT: markdown", "TEXT_START", content.page_content, "TEXT_END"],
),
]
@@ -90,11 +90,11 @@ async def add_extension_received_document(
# Generate unique identifier hash for this extension document (using URL)
unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.EXTENSION, content.metadata.VisitedWebPageURL, search_space_id
+ DocumentType.EXTENSION, content.metadata.VisitedWebPageURL, workspace_id
)
# Generate content hash
- content_hash = generate_content_hash(combined_document_string, search_space_id)
+ content_hash = generate_content_hash(combined_document_string, workspace_id)
# Check if document with this unique identifier already exists
existing_document = await check_document_by_unique_identifier(
@@ -126,7 +126,7 @@ async def add_extension_received_document(
summary_embedding = embed_text(summary_content)
# Process chunks
- chunks = await create_document_chunks(content.pageContent)
+ chunks = await create_document_chunks(content.page_content)
# Update or create document
if existing_document:
@@ -146,7 +146,7 @@ async def add_extension_received_document(
else:
# Create new document
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=content.metadata.VisitedWebPageTitle,
document_type=DocumentType.EXTENSION,
document_metadata=content.metadata.model_dump(),
diff --git a/surfsense_backend/app/tasks/document_processors/file_processors.py b/surfsense_backend/app/tasks/document_processors/file_processors.py
index 174ac966d..63862b62a 100644
--- a/surfsense_backend/app/tasks/document_processors/file_processors.py
+++ b/surfsense_backend/app/tasks/document_processors/file_processors.py
@@ -42,7 +42,7 @@ class _ProcessingContext:
session: AsyncSession
file_path: str
filename: str
- search_space_id: int
+ workspace_id: int
user_id: str
task_logger: TaskLoggingService
log_entry: Log
@@ -135,7 +135,7 @@ async def _process_non_document_upload(ctx: _ProcessingContext) -> Document | No
if ctx.use_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm = await get_vision_llm(ctx.session, ctx.search_space_id)
+ vision_llm = await get_vision_llm(ctx.session, ctx.workspace_id)
etl_result = await extract_with_cache(
EtlRequest(file_path=ctx.file_path, filename=ctx.filename),
@@ -151,7 +151,7 @@ async def _process_non_document_upload(ctx: _ProcessingContext) -> Document | No
ctx.session,
ctx.filename,
etl_result.markdown_content,
- ctx.search_space_id,
+ ctx.workspace_id,
ctx.user_id,
ctx.connector,
)
@@ -237,7 +237,7 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
if ctx.use_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm = await get_vision_llm(ctx.session, ctx.search_space_id)
+ vision_llm = await get_vision_llm(ctx.session, ctx.workspace_id)
etl_result = await extract_with_cache(
EtlRequest(
@@ -258,7 +258,7 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
ctx.session,
ctx.filename,
etl_result.markdown_content,
- ctx.search_space_id,
+ ctx.workspace_id,
ctx.user_id,
etl_result.etl_service,
ctx.connector,
@@ -302,7 +302,7 @@ async def _process_document_upload(ctx: _ProcessingContext) -> Document | None:
async def process_file_in_background(
file_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
session: AsyncSession,
task_logger: TaskLoggingService,
@@ -316,7 +316,7 @@ async def process_file_in_background(
session=session,
file_path=file_path,
filename=filename,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
task_logger=task_logger,
log_entry=log_entry,
@@ -368,7 +368,7 @@ async def process_file_in_background(
async def _extract_file_content(
file_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
session: AsyncSession,
user_id: str,
task_logger: TaskLoggingService,
@@ -432,7 +432,7 @@ async def _extract_file_content(
if use_vision_llm:
from app.services.llm_service import get_vision_llm
- vision_llm = await get_vision_llm(session, search_space_id)
+ vision_llm = await get_vision_llm(session, workspace_id)
from app.etl_pipeline.cache import extract_with_cache
@@ -459,7 +459,7 @@ async def process_file_in_background_with_document(
document: Document,
file_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
session: AsyncSession,
task_logger: TaskLoggingService,
@@ -491,7 +491,7 @@ async def process_file_in_background_with_document(
markdown_content, etl_service, billable_pages = await _extract_file_content(
file_path,
filename,
- search_space_id,
+ workspace_id,
session,
user_id,
task_logger,
@@ -504,7 +504,7 @@ async def process_file_in_background_with_document(
if not markdown_content:
raise RuntimeError(f"Failed to extract content from file: {filename}")
- content_hash = generate_content_hash(markdown_content, search_space_id)
+ content_hash = generate_content_hash(markdown_content, workspace_id)
existing_by_content = await check_duplicate_document(session, content_hash)
if existing_by_content and existing_by_content.id != doc_id:
logging.info(
@@ -525,7 +525,7 @@ async def process_file_in_background_with_document(
markdown_content=markdown_content,
filename=filename,
etl_service=etl_service,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id,
)
diff --git a/surfsense_backend/app/tasks/document_processors/markdown_processor.py b/surfsense_backend/app/tasks/document_processors/markdown_processor.py
index 463951a64..8632e212b 100644
--- a/surfsense_backend/app/tasks/document_processors/markdown_processor.py
+++ b/surfsense_backend/app/tasks/document_processors/markdown_processor.py
@@ -121,7 +121,7 @@ async def add_received_markdown_file_document(
session: AsyncSession,
file_name: str,
file_in_markdown: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
connector: dict | None = None,
) -> Document | None:
@@ -132,14 +132,14 @@ async def add_received_markdown_file_document(
session: Database session
file_name: Name of the markdown file
file_in_markdown: Content of the markdown file
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: ID of the user
connector: Optional connector info for Google Drive files
Returns:
Document object if successful, None if failed
"""
- task_logger = TaskLoggingService(session, search_space_id)
+ task_logger = TaskLoggingService(session, workspace_id)
# Log task start
log_entry = await task_logger.log_task_start(
@@ -156,11 +156,11 @@ async def add_received_markdown_file_document(
try:
# Generate unique identifier hash (uses file_id for Google Drive, filename for others)
primary_hash, legacy_hash = get_google_drive_unique_identifier(
- connector, file_name, search_space_id
+ connector, file_name, workspace_id
)
# Generate content hash
- content_hash = generate_content_hash(file_in_markdown, search_space_id)
+ content_hash = generate_content_hash(file_in_markdown, workspace_id)
# Check if document exists (with migration support for Google Drive and content_hash fallback)
existing_document = await find_existing_document_with_migration(
@@ -217,7 +217,7 @@ async def add_received_markdown_file_document(
doc_type = DocumentType.GOOGLE_DRIVE_FILE
document = Document(
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
title=file_name,
document_type=doc_type,
document_metadata={
diff --git a/surfsense_backend/app/tasks/document_processors/youtube_processor.py b/surfsense_backend/app/tasks/document_processors/youtube_processor.py
deleted file mode 100644
index dde5e4222..000000000
--- a/surfsense_backend/app/tasks/document_processors/youtube_processor.py
+++ /dev/null
@@ -1,473 +0,0 @@
-"""
-YouTube video document processor.
-
-Implements 2-phase document status updates for real-time UI feedback:
-- Phase 1: Create document with 'pending' status (visible in UI immediately)
-- Phase 2: Process document: pending → processing → ready/failed
-"""
-
-import logging
-import time
-from urllib.parse import parse_qs, urlparse
-
-from fake_useragent import UserAgent
-from requests import Session
-from scrapling.fetchers import AsyncFetcher
-from sqlalchemy.exc import SQLAlchemyError
-from sqlalchemy.ext.asyncio import AsyncSession
-from youtube_transcript_api import YouTubeTranscriptApi
-
-from app.db import Document, DocumentStatus, DocumentType
-from app.services.task_logging_service import TaskLoggingService
-from app.utils.document_converters import (
- create_document_chunks,
- embed_text,
- generate_content_hash,
- generate_unique_identifier_hash,
-)
-from app.utils.proxy import get_proxy_url, get_requests_proxies
-
-from .base import (
- check_document_by_unique_identifier,
- get_current_timestamp,
- safe_set_chunks,
-)
-
-
-def get_youtube_video_id(url: str) -> str | None:
- """
- Extract video ID from various YouTube URL formats.
-
- Args:
- url: YouTube URL
-
- Returns:
- Video ID if found, None otherwise
- """
- parsed_url = urlparse(url)
- hostname = parsed_url.hostname
-
- if hostname == "youtu.be":
- return parsed_url.path[1:]
- if hostname in ("www.youtube.com", "youtube.com"):
- if parsed_url.path == "/watch":
- query_params = parse_qs(parsed_url.query)
- return query_params.get("v", [None])[0]
- if parsed_url.path.startswith("/embed/"):
- return parsed_url.path.split("/")[2]
- if parsed_url.path.startswith("/v/"):
- return parsed_url.path.split("/")[2]
- return None
-
-
-async def add_youtube_video_document(
- session: AsyncSession,
- url: str,
- search_space_id: int,
- user_id: str,
- notification=None,
-) -> Document:
- """
- Process a YouTube video URL, extract transcripts, and store as a document.
-
- Implements 2-phase document status updates for real-time UI feedback:
- - Phase 1: Create document with 'pending' status (visible in UI immediately)
- - Phase 2: Process document: pending → processing → ready/failed
-
- Args:
- session: Database session for storing the document
- url: YouTube video URL (supports standard, shortened, and embed formats)
- search_space_id: ID of the search space to add the document to
- user_id: ID of the user
- notification: Optional notification object — if provided, the document_id
- is stored in its metadata right after document creation so the stale
- cleanup task can identify stuck documents.
-
- Returns:
- Document: The created document object
-
- Raises:
- ValueError: If the YouTube video ID cannot be extracted from the URL
- SQLAlchemyError: If there's a database error
- RuntimeError: If the video processing fails
- """
- task_logger = TaskLoggingService(session, search_space_id)
-
- # Log task start
- log_entry = await task_logger.log_task_start(
- task_name="youtube_video_document",
- source="background_task",
- message=f"Starting YouTube video processing for: {url}",
- metadata={"url": url, "user_id": str(user_id)},
- )
-
- document = None
- video_id = None
- is_new_document = False
-
- try:
- # Extract video ID from URL (lightweight operation)
- await task_logger.log_task_progress(
- log_entry,
- f"Extracting video ID from URL: {url}",
- {"stage": "video_id_extraction"},
- )
-
- video_id = get_youtube_video_id(url)
- if not video_id:
- raise ValueError(f"Could not extract video ID from URL: {url}")
-
- await task_logger.log_task_progress(
- log_entry,
- f"Video ID extracted: {video_id}",
- {"stage": "video_id_extracted", "video_id": video_id},
- )
-
- # Generate unique identifier hash for this YouTube video
- unique_identifier_hash = generate_unique_identifier_hash(
- DocumentType.YOUTUBE_VIDEO, video_id, search_space_id
- )
-
- # Check if document with this unique identifier already exists
- await task_logger.log_task_progress(
- log_entry,
- f"Checking for existing video: {video_id}",
- {"stage": "duplicate_check", "video_id": video_id},
- )
-
- existing_document = await check_document_by_unique_identifier(
- session, unique_identifier_hash
- )
-
- # =======================================================================
- # PHASE 1: Create pending document or prepare existing for update
- # =======================================================================
- if existing_document:
- document = existing_document
- is_new_document = False
- # Check if already being processed
- if DocumentStatus.is_state(
- existing_document.status, DocumentStatus.PENDING
- ):
- logging.info(
- f"YouTube video {video_id} already pending. Returning existing."
- )
- return existing_document
- if DocumentStatus.is_state(
- existing_document.status, DocumentStatus.PROCESSING
- ):
- logging.info(
- f"YouTube video {video_id} already processing. Returning existing."
- )
- return existing_document
- else:
- # Create new document with PENDING status (visible in UI immediately)
- await task_logger.log_task_progress(
- log_entry,
- f"Creating pending document for video: {video_id}",
- {"stage": "pending_document_creation"},
- )
-
- document = Document(
- title=f"YouTube Video: {video_id}", # Placeholder title
- document_type=DocumentType.YOUTUBE_VIDEO,
- document_metadata={
- "url": url,
- "video_id": video_id,
- },
- content="Processing video...", # Placeholder content
- content_hash=unique_identifier_hash, # Temporary unique value
- unique_identifier_hash=unique_identifier_hash,
- embedding=None,
- chunks=[], # Empty at creation
- status=DocumentStatus.pending(), # PENDING status - visible in UI
- search_space_id=search_space_id,
- updated_at=get_current_timestamp(),
- created_by_id=user_id,
- )
- session.add(document)
- await session.commit() # Document visible in UI now with pending status!
- is_new_document = True
-
- # Store document_id in notification metadata so stale cleanup task
- # can identify this document if the worker crashes.
- if notification and notification.notification_metadata is not None:
- from sqlalchemy.orm.attributes import flag_modified
-
- notification.notification_metadata["document_id"] = document.id
- flag_modified(notification, "notification_metadata")
- await session.commit()
-
- logging.info(f"Created pending document for YouTube video {video_id}")
-
- # =======================================================================
- # PHASE 2: Set to PROCESSING and do heavy work
- # =======================================================================
- document.status = DocumentStatus.processing()
- await session.commit() # UI shows "processing" status
-
- await task_logger.log_task_progress(
- log_entry,
- f"Fetching video metadata for: {video_id}",
- {"stage": "metadata_fetch"},
- )
-
- # Fetch video metadata
- params = {
- "format": "json",
- "url": f"https://www.youtube.com/watch?v={video_id}",
- }
- oembed_url = "https://www.youtube.com/oembed"
-
- # Build residential proxy settings (if configured)
- residential_proxies = get_requests_proxies()
-
- oembed_fetch_start = time.perf_counter()
- oembed_page = await AsyncFetcher.get(
- oembed_url,
- params=params,
- proxy=get_proxy_url(),
- stealthy_headers=True,
- )
- logging.info(
- "[youtube][perf] source=oembed video=%s status=%s fetch_ms=%.1f",
- video_id,
- getattr(oembed_page, "status", None),
- (time.perf_counter() - oembed_fetch_start) * 1000,
- )
- video_data = oembed_page.json()
-
- # Update title immediately for better UX (user sees actual title sooner)
- document.title = video_data.get("title", f"YouTube Video: {video_id}")
- await session.commit()
-
- await task_logger.log_task_progress(
- log_entry,
- f"Video metadata fetched: {video_data.get('title', 'Unknown')}",
- {
- "stage": "metadata_fetched",
- "title": video_data.get("title"),
- "author": video_data.get("author_name"),
- },
- )
-
- # Get video transcript
- await task_logger.log_task_progress(
- log_entry,
- f"Fetching transcript for video: {video_id}",
- {"stage": "transcript_fetch"},
- )
-
- try:
- transcript_fetch_start = time.perf_counter()
- ua = UserAgent()
- http_client = Session()
- http_client.headers.update({"User-Agent": ua.random})
- if residential_proxies:
- http_client.proxies.update(residential_proxies)
- ytt_api = YouTubeTranscriptApi(http_client=http_client)
-
- # List all available transcripts and pick the first one
- # (the video's primary language) instead of defaulting to English
- transcript_list = ytt_api.list(video_id)
- transcript = next(iter(transcript_list))
- captions = transcript.fetch()
- logging.info(
- "[youtube][perf] source=transcript video=%s fetch_ms=%.1f",
- video_id,
- (time.perf_counter() - transcript_fetch_start) * 1000,
- )
-
- # Include complete caption information with timestamps
- transcript_segments = []
- for line in captions:
- start_time = line.start
- duration = line.duration
- text = line.text
- timestamp = f"[{start_time:.2f}s-{start_time + duration:.2f}s]"
- transcript_segments.append(f"{timestamp} {text}")
- transcript_text = "\n".join(transcript_segments)
-
- await task_logger.log_task_progress(
- log_entry,
- f"Transcript fetched successfully: {len(captions)} segments ({transcript.language})",
- {
- "stage": "transcript_fetched",
- "segments_count": len(captions),
- "transcript_length": len(transcript_text),
- "language": transcript.language,
- "language_code": transcript.language_code,
- "is_generated": transcript.is_generated,
- },
- )
- except Exception as e:
- transcript_text = f"No captions available for this video. Error: {e!s}"
- await task_logger.log_task_progress(
- log_entry,
- f"No transcript available for video: {video_id}",
- {"stage": "transcript_unavailable", "error": str(e)},
- )
-
- # Format document
- await task_logger.log_task_progress(
- log_entry,
- f"Processing video content: {video_data.get('title', 'YouTube Video')}",
- {"stage": "content_processing"},
- )
-
- # Format document metadata in a more maintainable way
- metadata_sections = [
- (
- "METADATA",
- [
- f"TITLE: {video_data.get('title', 'YouTube Video')}",
- f"URL: {url}",
- f"VIDEO_ID: {video_id}",
- f"AUTHOR: {video_data.get('author_name', 'Unknown')}",
- f"THUMBNAIL: {video_data.get('thumbnail_url', '')}",
- ],
- ),
- (
- "CONTENT",
- ["FORMAT: transcript", "TEXT_START", transcript_text, "TEXT_END"],
- ),
- ]
-
- # Build the document string more efficiently
- document_parts = []
- document_parts.append("")
-
- for section_title, section_content in metadata_sections:
- document_parts.append(f"<{section_title}>")
- document_parts.extend(section_content)
- document_parts.append(f"{section_title}>")
-
- document_parts.append(" ")
- combined_document_string = "\n".join(document_parts)
-
- # Generate content hash
- content_hash = generate_content_hash(combined_document_string, search_space_id)
-
- # For existing documents, check if content has changed
- if not is_new_document and existing_document.content_hash == content_hash:
- await task_logger.log_task_success(
- log_entry,
- f"YouTube video document unchanged: {video_data.get('title', 'YouTube Video')}",
- {
- "duplicate_detected": True,
- "existing_document_id": existing_document.id,
- "video_id": video_id,
- },
- )
- logging.info(
- f"Document for YouTube video {video_id} unchanged. Marking as ready."
- )
- document.status = DocumentStatus.ready()
- await session.commit()
- return document
-
- summary_content = combined_document_string
- summary_embedding = embed_text(summary_content)
-
- # Process chunks
- await task_logger.log_task_progress(
- log_entry,
- f"Processing content chunks for video: {video_data.get('title', 'YouTube Video')}",
- {"stage": "chunk_processing"},
- )
-
- chunks = await create_document_chunks(combined_document_string)
-
- # =======================================================================
- # PHASE 3: Update document to READY with all content
- # =======================================================================
- await task_logger.log_task_progress(
- log_entry,
- f"Finalizing document: {video_data.get('title', 'YouTube Video')}",
- {"stage": "document_finalization", "chunks_count": len(chunks)},
- )
-
- document.title = video_data.get("title", "YouTube Video")
- document.content = summary_content
- document.content_hash = content_hash
- document.embedding = summary_embedding
- document.document_metadata = {
- "url": url,
- "video_id": video_id,
- "video_title": video_data.get("title", "YouTube Video"),
- "author": video_data.get("author_name", "Unknown"),
- "thumbnail": video_data.get("thumbnail_url", ""),
- }
- await safe_set_chunks(session, document, chunks)
- document.source_markdown = combined_document_string
- document.status = DocumentStatus.ready() # READY status - fully processed
- document.updated_at = get_current_timestamp()
-
- await session.commit()
- await session.refresh(document)
-
- # Log success
- await task_logger.log_task_success(
- log_entry,
- f"Successfully processed YouTube video: {video_data.get('title', 'YouTube Video')}",
- {
- "document_id": document.id,
- "video_id": video_id,
- "title": document.title,
- "content_hash": content_hash,
- "chunks_count": len(chunks),
- "summary_length": len(summary_content),
- "has_transcript": "No captions available" not in transcript_text,
- },
- )
-
- return document
-
- except SQLAlchemyError as db_error:
- # Mark document as failed if it exists
- if document:
- try:
- document.status = DocumentStatus.failed(
- f"Database error: {str(db_error)[:150]}"
- )
- document.updated_at = get_current_timestamp()
- await session.commit()
- except Exception:
- await session.rollback()
- else:
- await session.rollback()
-
- await task_logger.log_task_failure(
- log_entry,
- f"Database error while processing YouTube video: {url}",
- str(db_error),
- {
- "error_type": "SQLAlchemyError",
- "video_id": video_id,
- },
- )
- raise db_error
-
- except Exception as e:
- # Mark document as failed if it exists
- if document:
- try:
- document.status = DocumentStatus.failed(str(e)[:200])
- document.updated_at = get_current_timestamp()
- await session.commit()
- except Exception:
- await session.rollback()
- else:
- await session.rollback()
-
- await task_logger.log_task_failure(
- log_entry,
- f"Failed to process YouTube video: {url}",
- str(e),
- {
- "error_type": type(e).__name__,
- "video_id": video_id,
- },
- )
- logging.error(f"Failed to process YouTube video: {e!s}")
- raise
diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py
index bf9ec74d1..dbec93876 100644
--- a/surfsense_backend/app/users.py
+++ b/surfsense_backend/app/users.py
@@ -22,10 +22,10 @@ from app.auth.session_cookies import access_expires_at, write_session
from app.config import config
from app.db import (
Prompt,
- SearchSpace,
- SearchSpaceMembership,
- SearchSpaceRole,
User,
+ Workspace,
+ WorkspaceMembership,
+ WorkspaceRole,
async_session_maker,
get_async_session,
get_default_roles_config,
@@ -146,34 +146,34 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
async def on_after_register(self, user: User, request: Request | None = None):
"""
- Called after a user registers. Creates a default search space for the user
+ Called after a user registers. Creates a default workspace for the user
so they can start chatting immediately without manual setup.
"""
- logger.info(f"User {user.id} has registered. Creating default search space...")
+ logger.info(f"User {user.id} has registered. Creating default workspace...")
try:
async with async_session_maker() as session:
- # Create default search space
- default_search_space = SearchSpace(
- name="My Search Space",
- description="Your personal search space",
+ # Create default workspace
+ default_workspace = Workspace(
+ name="My Workspace",
+ description="Your personal workspace",
user_id=user.id,
)
- session.add(default_search_space)
- await session.flush() # Get the search space ID
+ session.add(default_workspace)
+ await session.flush() # Get the workspace ID
# Create default roles
default_roles = get_default_roles_config()
owner_role_id = None
for role_config in default_roles:
- db_role = SearchSpaceRole(
+ db_role = WorkspaceRole(
name=role_config["name"],
description=role_config["description"],
permissions=role_config["permissions"],
is_default=role_config["is_default"],
is_system_role=role_config["is_system_role"],
- search_space_id=default_search_space.id,
+ workspace_id=default_workspace.id,
)
session.add(db_role)
await session.flush()
@@ -182,9 +182,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
owner_role_id = db_role.id
# Create owner membership
- owner_membership = SearchSpaceMembership(
+ owner_membership = WorkspaceMembership(
user_id=user.id,
- search_space_id=default_search_space.id,
+ workspace_id=default_workspace.id,
role_id=owner_role_id,
is_owner=True,
)
@@ -204,12 +204,10 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
await session.commit()
logger.info(
- f"Created default search space (ID: {default_search_space.id}) for user {user.id}"
+ f"Created default workspace (ID: {default_workspace.id}) for user {user.id}"
)
except Exception as e:
- logger.error(
- f"Failed to create default search space for user {user.id}: {e}"
- )
+ logger.error(f"Failed to create default workspace for user {user.id}: {e}")
async def on_after_forgot_password(
self, user: User, token: str, request: Request | None = None
@@ -326,7 +324,7 @@ def _token_meets_epoch(token: str) -> bool:
return False
issued_at = payload.get("iat")
- return isinstance(issued_at, (int, float)) and int(issued_at) >= min_issued_at
+ return isinstance(issued_at, int | float) and int(issued_at) >= min_issued_at
async def get_auth_context(
@@ -384,7 +382,7 @@ async def allow_any_principal(
) -> AuthContext:
"""Allow either session or PAT principals for bootstrap probes only.
- Routes using this dependency intentionally have no search-space gate.
+ Routes using this dependency intentionally have no workspace gate.
Adding a new call site is a security decision and must be covered by
the fail-closed PAT allowlist test.
"""
diff --git a/surfsense_backend/app/utils/captcha/__init__.py b/surfsense_backend/app/utils/captcha/__init__.py
new file mode 100644
index 000000000..499eaa876
--- /dev/null
+++ b/surfsense_backend/app/utils/captcha/__init__.py
@@ -0,0 +1,22 @@
+"""App-wide captcha-solver configuration (Apache-2.0, generic glue).
+
+This package holds only the **generic, vendor-agnostic** config resolution for
+captcha solving — mirroring ``app/utils/proxy/`` (which stays Apache-2.0 even
+though the crawler that consumes it is proprietary). The actual bypass logic
+(challenge detection + token injection ``page_action``) lives in the separately
+licensed ``app/proprietary/web_crawler/captcha.py``.
+
+``captchatools`` is itself the multi-vendor registry (capmonster / 2captcha /
+anticaptcha / capsolver / captchaai), so there is no provider hierarchy here —
+just one env-driven :class:`CaptchaConfig` and a cheap ``captcha_enabled()``
+gate callers check before constructing anything (mirrors
+``WebCrawlCreditService.billing_enabled()``).
+"""
+
+from app.utils.captcha.config import (
+ CaptchaConfig,
+ captcha_enabled,
+ get_captcha_config,
+)
+
+__all__ = ["CaptchaConfig", "captcha_enabled", "get_captcha_config"]
diff --git a/surfsense_backend/app/utils/captcha/config.py b/surfsense_backend/app/utils/captcha/config.py
new file mode 100644
index 000000000..96d1dada9
--- /dev/null
+++ b/surfsense_backend/app/utils/captcha/config.py
@@ -0,0 +1,54 @@
+"""Env-resolved captcha-solver config (one app-wide config; no per-connector).
+
+Resolved from :data:`app.config.config` only, mirroring ``03b``'s single
+``PROXY_PROVIDER`` model. Off by default: ``captcha_enabled()`` is ``False``
+unless both ``CAPTCHA_SOLVING_ENABLED=TRUE`` and an API key are present, so a
+misconfigured deployment (flag on, key missing) never attempts a paid solve.
+"""
+
+from dataclasses import dataclass
+
+from app.config import config
+
+
+@dataclass(frozen=True)
+class CaptchaConfig:
+ """Immutable snapshot of the captcha-solver settings for one crawl."""
+
+ enabled: bool
+ solving_site: str
+ api_key: str | None
+ max_attempts_per_url: int
+ timeout_s: int
+ captcha_type_default: str
+ v3_min_score: float
+ v3_action: str
+
+
+def get_captcha_config() -> CaptchaConfig:
+ """Build a :class:`CaptchaConfig` from the current process config/env."""
+ api_key = config.CAPTCHA_SOLVER_API_KEY
+ flag_on = config.CAPTCHA_SOLVING_ENABLED
+ return CaptchaConfig(
+ # Effective-enabled requires a key too: a flag with no key would only
+ # produce ``ErrWrongAPIKey`` per attempt (and still risk an IP ban from
+ # the solver), so treat key-less config as disabled.
+ enabled=bool(flag_on and api_key),
+ solving_site=config.CAPTCHA_SOLVER_PROVIDER,
+ api_key=api_key,
+ max_attempts_per_url=config.CAPTCHA_MAX_ATTEMPTS_PER_URL,
+ timeout_s=config.CAPTCHA_SOLVE_TIMEOUT_S,
+ captcha_type_default=config.CAPTCHA_TYPE_DEFAULT,
+ v3_min_score=config.CAPTCHA_V3_MIN_SCORE,
+ v3_action=config.CAPTCHA_V3_ACTION,
+ )
+
+
+def captcha_enabled() -> bool:
+ """Cheap gate: is captcha solving effectively configured (flag + key)?
+
+ Callers check this before building the ``page_action`` or capturing the
+ crawl's proxy endpoint for the solver, so the stealth tier stays unchanged
+ when solving is off.
+ """
+ return bool(config.CAPTCHA_SOLVING_ENABLED and config.CAPTCHA_SOLVER_API_KEY)
diff --git a/surfsense_backend/app/utils/connector_naming.py b/surfsense_backend/app/utils/connector_naming.py
index 99c8243a5..502e89775 100644
--- a/surfsense_backend/app/utils/connector_naming.py
+++ b/surfsense_backend/app/utils/connector_naming.py
@@ -118,14 +118,14 @@ def generate_connector_name_with_identifier(
async def count_connectors_of_type(
session: AsyncSession,
connector_type: SearchSourceConnectorType,
- search_space_id: int,
+ workspace_id: int,
user_id: UUID,
) -> int:
- """Count existing connectors of a type for a user in a search space."""
+ """Count existing connectors of a type for a user in a workspace."""
result = await session.execute(
select(func.count(SearchSourceConnector.id)).where(
SearchSourceConnector.connector_type == connector_type,
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
)
)
@@ -135,7 +135,7 @@ async def count_connectors_of_type(
async def check_duplicate_connector(
session: AsyncSession,
connector_type: SearchSourceConnectorType,
- search_space_id: int,
+ workspace_id: int,
user_id: UUID,
identifier: str | None,
) -> bool:
@@ -145,7 +145,7 @@ async def check_duplicate_connector(
Args:
session: Database session
connector_type: The type of connector
- search_space_id: The search space ID
+ workspace_id: The workspace ID
user_id: The user ID
identifier: User identifier (email, workspace name, etc.)
@@ -159,7 +159,7 @@ async def check_duplicate_connector(
result = await session.execute(
select(func.count(SearchSourceConnector.id)).where(
SearchSourceConnector.connector_type == connector_type,
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.name == expected_name,
)
@@ -170,11 +170,11 @@ async def check_duplicate_connector(
async def ensure_unique_connector_name(
session: AsyncSession,
name: str,
- search_space_id: int,
+ workspace_id: int,
user_id: UUID,
) -> str:
"""
- Ensure a connector name is unique within a user's search space.
+ Ensure a connector name is unique within a user's workspace.
If the name already exists, appends a counter suffix: (2), (3), etc.
Uses the same suffix format as generate_unique_connector_name.
@@ -182,7 +182,7 @@ async def ensure_unique_connector_name(
Args:
session: Database session
name: Desired connector name
- search_space_id: The search space ID
+ workspace_id: The workspace ID
user_id: The user ID
Returns:
@@ -190,7 +190,7 @@ async def ensure_unique_connector_name(
"""
result = await session.execute(
select(SearchSourceConnector.name).where(
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
)
)
@@ -208,7 +208,7 @@ async def ensure_unique_connector_name(
async def generate_unique_connector_name(
session: AsyncSession,
connector_type: SearchSourceConnectorType,
- search_space_id: int,
+ workspace_id: int,
user_id: UUID,
identifier: str | None = None,
) -> str:
@@ -221,7 +221,7 @@ async def generate_unique_connector_name(
Args:
session: Database session
connector_type: The type of connector
- search_space_id: The search space ID
+ workspace_id: The workspace ID
user_id: The user ID
identifier: Optional user identifier (email, workspace name, etc.)
@@ -235,12 +235,12 @@ async def generate_unique_connector_name(
return await ensure_unique_connector_name(
session,
name,
- search_space_id,
+ workspace_id,
user_id,
)
count = await count_connectors_of_type(
- session, connector_type, search_space_id, user_id
+ session, connector_type, workspace_id, user_id
)
if count == 0:
diff --git a/surfsense_backend/app/utils/crawl/__init__.py b/surfsense_backend/app/utils/crawl/__init__.py
new file mode 100644
index 000000000..3f4762414
--- /dev/null
+++ b/surfsense_backend/app/utils/crawl/__init__.py
@@ -0,0 +1,22 @@
+"""App-wide crawler block classification (Apache-2.0, generic).
+
+Phase 3e (Slice A). Mirrors ``app/utils/proxy`` and ``app/utils/captcha``: this
+package holds only the **generic, vendor-agnostic** glue — here, a pure block
+classifier (passive telemetry from public anti-bot markers). It is consumed by
+the separately licensed proprietary crawler to label ``CrawlOutcome.block_type``.
+
+The **bypass-specific tuning** (the stealth kwargs builder / geoip coherence, and
+the deferred WebGL spoof + humanize choreography) is NOT generic and lives under
+the proprietary boundary in ``app/proprietary/web_crawler/`` (``stealth.py``).
+"""
+
+from app.utils.crawl.classifier import BlockType, classify_block
+from app.utils.crawl.contacts import Contacts, extract_contacts, is_social_host
+
+__all__ = [
+ "BlockType",
+ "Contacts",
+ "classify_block",
+ "extract_contacts",
+ "is_social_host",
+]
diff --git a/surfsense_backend/app/utils/crawl/classifier.py b/surfsense_backend/app/utils/crawl/classifier.py
new file mode 100644
index 000000000..e58a55537
--- /dev/null
+++ b/surfsense_backend/app/utils/crawl/classifier.py
@@ -0,0 +1,94 @@
+"""Block classifier — labels a fetched page so the ladder can learn (Phase 3e).
+
+Pure, additive, status + body-marker based. It attaches
+:class:`~app.proprietary.web_crawler.connector.CrawlOutcome.block_type` for
+telemetry / future escalation routing (per-domain memory, `03d` captcha routing)
+and **never** changes *when* a crawl is ``SUCCESS`` — `03c` bills on
+``status == SUCCESS``, so this stays read-only metadata.
+
+Markers mirror the proven set in the Camoufox-based FlareSolverr alternative
+``references/trawl-dev/packages/tiers/src/detect.ts`` (plus DataDome/Kasada for
+our enterprise targets). Regexes are compiled once at import (hot-path hygiene).
+"""
+
+import re
+from enum import StrEnum
+
+
+class BlockType(StrEnum):
+ """Coarse label for what a fetched page represents."""
+
+ OK = "ok" # usable content, no challenge detected
+ CLOUDFLARE = "cloudflare" # CF interstitial / Turnstile / DDoS-Guard
+ CAPTCHA_RECAPTCHA = "captcha_recaptcha"
+ CAPTCHA_HCAPTCHA = "captcha_hcaptcha"
+ DATADOME = "datadome"
+ KASADA = "kasada"
+ RATE_LIMITED = "rate_limited"
+ EMPTY = "empty" # fetched but no HTML body
+ UNKNOWN = "unknown" # blocking-ish status with no recognized marker
+
+
+# --- compiled marker patterns (hoisted; see Vercel rule 7.9) ---
+_CLOUDFLARE = re.compile(
+ r"just a moment"
+ r"|checking your browser"
+ r"|enable javascript and cookies to continue"
+ r"|verify you are human"
+ r"|cf-mitigated"
+ r"|id=\"(?:cf-)?challenge-running\""
+ r"|id=\"turnstile-wrapper\""
+ r"|ddos-guard",
+ re.IGNORECASE,
+)
+_TURNSTILE = re.compile(
+ r"class=\"cf-turnstile\""
+ r"|challenges\.cloudflare\.com/turnstile"
+ r"|cdn-cgi/challenge-platform[^\"']*turnstile",
+ re.IGNORECASE,
+)
+_HCAPTCHA = re.compile(r"class=\"h-captcha\"|hcaptcha\.com/1/api", re.IGNORECASE)
+_RECAPTCHA = re.compile(
+ r"class=\"g-recaptcha\"|google\.com/recaptcha|recaptcha\.net/recaptcha",
+ re.IGNORECASE,
+)
+_DATADOME = re.compile(r"datadome|geo\.captcha-delivery\.com", re.IGNORECASE)
+_KASADA = re.compile(r"kpsdk|x-kpsdk|kasada", re.IGNORECASE)
+
+# Statuses some CDNs use as a bot-gate before the real response (detect.ts:isBlocked).
+_BOT_GATE_STATUSES = frozenset({202, 403})
+
+
+def classify_block(status: int | None, html: str | None) -> BlockType:
+ """Label a fetched page from its HTTP status and HTML body.
+
+ Precedence: explicit rate-limit status, then specific anti-bot/captcha body
+ markers, then a generic bot-gate status with no marker, else ``OK`` (or
+ ``EMPTY`` when there is no body). Marker checks run before the generic
+ bot-gate so a ``403`` Cloudflare challenge is labeled ``CLOUDFLARE``, not
+ ``UNKNOWN``.
+ """
+ if status == 429:
+ return BlockType.RATE_LIMITED
+
+ if not html or not html.strip():
+ # No body: distinguish a blocking-ish status from a genuinely empty 200.
+ if status in _BOT_GATE_STATUSES:
+ return BlockType.UNKNOWN
+ return BlockType.EMPTY
+
+ if _CLOUDFLARE.search(html) or _TURNSTILE.search(html):
+ return BlockType.CLOUDFLARE
+ if _DATADOME.search(html):
+ return BlockType.DATADOME
+ if _KASADA.search(html):
+ return BlockType.KASADA
+ if _HCAPTCHA.search(html):
+ return BlockType.CAPTCHA_HCAPTCHA
+ if _RECAPTCHA.search(html):
+ return BlockType.CAPTCHA_RECAPTCHA
+
+ if status in _BOT_GATE_STATUSES:
+ return BlockType.UNKNOWN
+
+ return BlockType.OK
diff --git a/surfsense_backend/app/utils/crawl/contacts.py b/surfsense_backend/app/utils/crawl/contacts.py
new file mode 100644
index 000000000..872db33d9
--- /dev/null
+++ b/surfsense_backend/app/utils/crawl/contacts.py
@@ -0,0 +1,278 @@
+"""Pure contact/social-signal extraction from raw HTML (Apache-2.0, generic).
+
+Lead-gen / competitive-intelligence crawls need the emails, phone numbers, and
+social profiles a site publishes — which almost always live in the footer, the
+contact page, or the privacy/terms pages. Trafilatura's main-content extraction
+deliberately drops that boilerplate, so these signals must be pulled from the
+raw HTML, not the cleaned markdown.
+
+No I/O and no bypass logic, so this sits in the generic ``app/utils/crawl``
+package (mirrors ``classifier``) and is consumed by the proprietary connector.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from urllib.parse import unquote, urldefrag, urlsplit
+
+from lxml import html as lxml_html
+from lxml.etree import ParserError
+
+# Social/profile hosts worth surfacing as leads. Matched on host == d or a
+# subdomain of d. ``x.com``/``twitter.com`` both kept (rename churn).
+_SOCIAL_HOSTS = (
+ "twitter.com",
+ "x.com",
+ "linkedin.com",
+ "facebook.com",
+ "fb.com",
+ "instagram.com",
+ "youtube.com",
+ "youtu.be",
+ "github.com",
+ "gitlab.com",
+ "tiktok.com",
+ "discord.com",
+ "discord.gg",
+ "t.me",
+ "medium.com",
+ "threads.net",
+ "pinterest.com",
+ "reddit.com",
+ "crunchbase.com",
+ "wellfound.com",
+ "angel.co",
+ "mastodon.social",
+ "bsky.app",
+ # Regional networks — the primary business contact channel in much of the
+ # world (WhatsApp: LatAm/India/Africa; Line: JP/TH/TW; VK/OK: RU;
+ # Weibo/WeChat: CN; Xing: DACH; Kakao: KR).
+ "wa.me",
+ "whatsapp.com",
+ "line.me",
+ "lin.ee",
+ "vk.com",
+ "ok.ru",
+ "weibo.com",
+ "weixin.qq.com",
+ "xing.com",
+ "pf.kakao.com",
+)
+
+# Email domains that are almost never a real contact (SDKs, CDNs, examples).
+_NOISE_EMAIL_DOMAINS = frozenset(
+ {
+ "sentry.io",
+ "wixpress.com",
+ "example.com",
+ "example.org",
+ "domain.com",
+ "email.com",
+ # Unambiguous placeholder domains; ambiguous ones (business.com,
+ # company.com) are left to the placeholder local-part filter instead.
+ "yourcompany.com",
+ "yourdomain.com",
+ "yoursite.com",
+ "schema.org",
+ "w3.org",
+ "googleapis.com",
+ "gstatic.com",
+ "sentry-cdn.com",
+ "cloudflare.com",
+ }
+)
+
+# File extensions that surface as bogus email "TLDs" when an asset ref (``logo@2x.png``)
+# or version-pinned dep (``react@18.2.0.js``) matches the email shape.
+_ASSET_TLDS = frozenset(
+ {
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "svg",
+ "webp",
+ "ico",
+ "bmp",
+ "css",
+ "js",
+ "mjs",
+ "cjs",
+ "ts",
+ "map",
+ "json",
+ "xml",
+ "woff",
+ "woff2",
+ "ttf",
+ "eot",
+ "otf",
+ "php",
+ "html",
+ "htm",
+ }
+)
+
+_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
+
+# Template/form placeholders, compared after stripping [._-] separators, so
+# "your.email"/"your-email"/"youremail" all match. Deliberately excludes real
+# common locals like hello/info/contact/support.
+_PLACEHOLDER_EMAIL_LOCALS = frozenset(
+ {
+ "youremail",
+ "yourname",
+ "youraddress",
+ "myemail",
+ "email",
+ "name",
+ "user",
+ "username",
+ "someone",
+ "somebody",
+ "johndoe",
+ "janedoe",
+ "firstname",
+ "lastname",
+ "firstnamelastname",
+ "firstlast",
+ "test",
+ "example",
+ "sample",
+ "placeholder",
+ }
+)
+
+# Placeholder profile handles left in site templates ("github.com/username").
+_PLACEHOLDER_SOCIAL_SEGMENTS = frozenset(
+ {
+ "username",
+ "yourusername",
+ "yourhandle",
+ "handle",
+ "user",
+ "profile",
+ "yourprofile",
+ "yourname",
+ "yourpage",
+ "pagename",
+ "youraccount",
+ "account",
+ "example",
+ "placeholder",
+ "yourcompany",
+ }
+)
+
+_SEPARATORS_RE = re.compile(r"[._\-]+")
+
+
+def _normalized(token: str) -> str:
+ return _SEPARATORS_RE.sub("", token.strip().lower().lstrip("@"))
+
+
+@dataclass
+class Contacts:
+ """Deduped contact signals harvested from one page's raw HTML."""
+
+ emails: list[str] = field(default_factory=list)
+ phones: list[str] = field(default_factory=list)
+ socials: list[str] = field(default_factory=list)
+
+ def as_dict(self) -> dict[str, list[str]]:
+ return {"emails": self.emails, "phones": self.phones, "socials": self.socials}
+
+ @property
+ def is_empty(self) -> bool:
+ return not (self.emails or self.phones or self.socials)
+
+
+def is_social_host(host: str) -> bool:
+ """True when ``host`` is (a subdomain of) a known social/profile host."""
+ return any(host == d or host.endswith("." + d) for d in _SOCIAL_HOSTS)
+
+
+def _keep_email(email: str) -> bool:
+ local, _, domain = email.partition("@")
+ domain = domain.lower()
+ if domain in _NOISE_EMAIL_DOMAINS:
+ return False
+ if _normalized(local) in _PLACEHOLDER_EMAIL_LOCALS:
+ return False
+ # Drops asset/version false positives like ``logo@2x.png`` / ``react@18.2.0.js``
+ # whose trailing token is a file extension, not a real TLD.
+ return domain.rsplit(".", 1)[-1] not in _ASSET_TLDS
+
+
+def _keep_social(url: str) -> bool:
+ # ponytail: any placeholder-looking path segment drops the URL; a real
+ # handle literally named "username"/"example" is collateral. Upgrade path:
+ # per-host handle position rules (e.g. linkedin.com/in/).
+ return not any(
+ _normalized(segment) in _PLACEHOLDER_SOCIAL_SEGMENTS
+ for segment in urlsplit(url).path.split("/")
+ if segment
+ )
+
+
+def _dedup(values: list[str]) -> list[str]:
+ """Case-insensitive dedupe that preserves first-seen order."""
+ seen: set[str] = set()
+ out: list[str] = []
+ for value in values:
+ key = value.lower()
+ if key not in seen:
+ seen.add(key)
+ out.append(value)
+ return out
+
+
+def extract_contacts(raw_html: str | None) -> Contacts:
+ """Harvest emails, phone numbers, and social profile URLs from raw HTML.
+
+ Emails come from ``mailto:`` hrefs (high confidence) and a plaintext scan of
+ the source (noise-filtered). Phones come only from ``tel:`` hrefs — a text
+ scan for phone numbers is too noisy to be worth it. Socials are ``href``
+ targets on known profile hosts. Any parse error yields empty results rather
+ than aborting the crawl.
+ """
+ if not raw_html or not raw_html.strip():
+ return Contacts()
+
+ emails: list[str] = []
+ phones: list[str] = []
+ socials: list[str] = []
+
+ try:
+ root = lxml_html.fromstring(raw_html)
+ except (ParserError, ValueError):
+ root = None
+
+ if root is not None:
+ for href in root.xpath("//a/@href | //link/@href"):
+ href = str(href).strip()
+ low = href.lower()
+ # unquote: hrefs URL-encode spaces etc. ("tel:+1%20408-629-1770")
+ if low.startswith("mailto:"):
+ addr = unquote(urlsplit(href).path.split("?")[0]).strip()
+ if addr:
+ emails.append(addr)
+ elif low.startswith("tel:"):
+ num = unquote(urlsplit(href).path).strip()
+ if num:
+ phones.append(num)
+ elif low.startswith(("http://", "https://")):
+ host = (urlsplit(href).hostname or "").lower()
+ if is_social_host(host):
+ socials.append(urldefrag(href)[0])
+
+ # Plaintext email scan over the source catches addresses rendered as text
+ # (e.g. "hello@site.com" in a footer) that never appear as a mailto href.
+ emails.extend(_EMAIL_RE.findall(raw_html))
+
+ return Contacts(
+ emails=[e for e in _dedup(emails) if _keep_email(e)],
+ phones=_dedup(phones),
+ socials=[s for s in _dedup(socials) if _keep_social(s)],
+ )
diff --git a/surfsense_backend/app/utils/document_converters.py b/surfsense_backend/app/utils/document_converters.py
index bd8740358..3c3e2a562 100644
--- a/surfsense_backend/app/utils/document_converters.py
+++ b/surfsense_backend/app/utils/document_converters.py
@@ -257,28 +257,28 @@ async def convert_document_to_markdown(elements):
return "".join(markdown_parts)
-def generate_content_hash(content: str, search_space_id: int) -> str:
- """Generate SHA-256 hash for the given content combined with search space ID."""
- combined_data = f"{search_space_id}:{content}"
+def generate_content_hash(content: str, workspace_id: int) -> str:
+ """Generate SHA-256 hash for the given content combined with workspace ID."""
+ combined_data = f"{workspace_id}:{content}"
return hashlib.sha256(combined_data.encode("utf-8")).hexdigest()
def generate_unique_identifier_hash(
document_type: DocumentType,
unique_identifier: str | int | float,
- search_space_id: int,
+ workspace_id: int,
) -> str:
"""
Generate SHA-256 hash for a unique document identifier from connector sources.
This function creates a consistent hash based on the document type, its unique
- identifier from the source system, and the search space ID. This helps prevent
+ identifier from the source system, and the workspace ID. This helps prevent
duplicate documents when syncing from various connectors like Slack, Notion, Jira, etc.
Args:
document_type: The type of document (e.g., SLACK_CONNECTOR, NOTION_CONNECTOR)
unique_identifier: The unique ID from the source system (e.g., message ID, page ID)
- search_space_id: The search space this document belongs to
+ workspace_id: The workspace this document belongs to
Returns:
str: SHA-256 hash string representing the unique document identifier
@@ -294,7 +294,7 @@ def generate_unique_identifier_hash(
# Convert unique_identifier to string to handle different types
identifier_str = str(unique_identifier)
- # Combine document type value, unique identifier, and search space ID
- combined_data = f"{document_type.value}:{identifier_str}:{search_space_id}"
+ # Combine document type value, unique identifier, and workspace ID
+ combined_data = f"{document_type.value}:{identifier_str}:{workspace_id}"
return hashlib.sha256(combined_data.encode("utf-8")).hexdigest()
diff --git a/surfsense_backend/app/utils/oauth_security.py b/surfsense_backend/app/utils/oauth_security.py
index c39b1e9b1..691e96687 100644
--- a/surfsense_backend/app/utils/oauth_security.py
+++ b/surfsense_backend/app/utils/oauth_security.py
@@ -63,7 +63,7 @@ class OAuthStateManager:
Generate cryptographically signed state parameter.
Args:
- space_id: The search space ID
+ space_id: The workspace ID
user_id: The user ID
**extra_fields: Additional fields to include in state (e.g., code_verifier for PKCE)
diff --git a/surfsense_backend/app/utils/periodic_scheduler.py b/surfsense_backend/app/utils/periodic_scheduler.py
index 35e8ad781..992f5038f 100644
--- a/surfsense_backend/app/utils/periodic_scheduler.py
+++ b/surfsense_backend/app/utils/periodic_scheduler.py
@@ -22,14 +22,13 @@ CONNECTOR_TASK_MAP = {
SearchSourceConnectorType.GITHUB_CONNECTOR: "index_github_repos",
SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "index_confluence_pages",
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: "index_elasticsearch_documents",
- SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: "index_crawled_urls",
SearchSourceConnectorType.BOOKSTACK_CONNECTOR: "index_bookstack_pages",
}
def create_periodic_schedule(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
connector_type: SearchSourceConnectorType,
frequency_minutes: int,
@@ -44,7 +43,7 @@ def create_periodic_schedule(
Args:
connector_id: ID of the connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: User ID
connector_type: Type of connector
frequency_minutes: Frequency in minutes (used for logging)
@@ -54,20 +53,6 @@ def create_periodic_schedule(
True if successful, False otherwise
"""
try:
- # Special handling for connectors that require config validation
- if connector_type == SearchSourceConnectorType.WEBCRAWLER_CONNECTOR:
- from app.utils.webcrawler_utils import parse_webcrawler_urls
-
- config = connector_config or {}
- urls = parse_webcrawler_urls(config.get("INITIAL_URLS"))
-
- if not urls:
- logger.info(
- f"Webcrawler connector {connector_id} has no URLs configured, "
- "skipping first indexing run (will run when URLs are added)"
- )
- return True # Return success - schedule is created, just no first run
-
logger.info(
f"Periodic indexing enabled for connector {connector_id} "
f"(frequency: {frequency_minutes} minutes). Triggering first run..."
@@ -76,7 +61,6 @@ def create_periodic_schedule(
from app.tasks.celery_tasks.connector_tasks import (
index_bookstack_pages_task,
index_confluence_pages_task,
- index_crawled_urls_task,
index_elasticsearch_documents_task,
index_github_repos_task,
index_notion_pages_task,
@@ -87,14 +71,13 @@ def create_periodic_schedule(
SearchSourceConnectorType.GITHUB_CONNECTOR: index_github_repos_task,
SearchSourceConnectorType.CONFLUENCE_CONNECTOR: index_confluence_pages_task,
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: index_elasticsearch_documents_task,
- SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: index_crawled_urls_task,
SearchSourceConnectorType.BOOKSTACK_CONNECTOR: index_bookstack_pages_task,
}
# Trigger the first run immediately
task = task_map.get(connector_type)
if task:
- task.delay(connector_id, search_space_id, user_id, None, None)
+ task.delay(connector_id, workspace_id, user_id, None, None)
logger.info(
f"✓ First indexing run triggered for connector {connector_id}. "
f"Periodic indexing will continue automatically every {frequency_minutes} minutes."
@@ -133,7 +116,7 @@ def delete_periodic_schedule(connector_id: int) -> bool:
def update_periodic_schedule(
connector_id: int,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
connector_type: SearchSourceConnectorType,
frequency_minutes: int,
@@ -146,7 +129,7 @@ def update_periodic_schedule(
Args:
connector_id: ID of the connector
- search_space_id: ID of the search space
+ workspace_id: ID of the workspace
user_id: User ID
connector_type: Type of connector
frequency_minutes: New frequency in minutes
@@ -160,5 +143,5 @@ def update_periodic_schedule(
)
# Optionally trigger an immediate run with the new schedule
# Uncomment the line below if you want immediate execution on schedule update
- # return create_periodic_schedule(connector_id, search_space_id, user_id, connector_type, frequency_minutes)
+ # return create_periodic_schedule(connector_id, workspace_id, user_id, connector_type, frequency_minutes)
return True
diff --git a/surfsense_backend/app/utils/proxy/__init__.py b/surfsense_backend/app/utils/proxy/__init__.py
index 8ff489a41..3e4158587 100644
--- a/surfsense_backend/app/utils/proxy/__init__.py
+++ b/surfsense_backend/app/utils/proxy/__init__.py
@@ -25,6 +25,14 @@ def get_requests_proxies() -> dict[str, str] | None:
return get_active_provider().get_requests_proxies()
+def is_pool_backed() -> bool:
+ """Whether the active provider rotates across a client-side pool of endpoints.
+
+ The crawler gates its bounded proxy-error rotation-retry on this.
+ """
+ return get_active_provider().is_pool_backed
+
+
def get_residential_proxy_url() -> str | None:
"""Backward-compatible alias for :func:`get_proxy_url`."""
return get_proxy_url()
@@ -37,4 +45,5 @@ __all__ = [
"get_proxy_url",
"get_requests_proxies",
"get_residential_proxy_url",
+ "is_pool_backed",
]
diff --git a/surfsense_backend/app/utils/proxy/base.py b/surfsense_backend/app/utils/proxy/base.py
index a3e84faf0..ed67cd384 100644
--- a/surfsense_backend/app/utils/proxy/base.py
+++ b/surfsense_backend/app/utils/proxy/base.py
@@ -15,6 +15,7 @@ registering it in ``registry.py``.
"""
from abc import ABC, abstractmethod
+from urllib.parse import urlsplit
class ProxyProvider(ABC):
@@ -27,12 +28,33 @@ class ProxyProvider(ABC):
def get_proxy_url(self) -> str | None:
"""Return ``http://user:pass@host:port`` (no trailing slash), or ``None``.
- This is the canonical form Scrapling/curl_cffi consume directly.
+ This is the canonical form Scrapling/curl_cffi consume directly, and the
+ single source every provider must supply — the ``requests`` and Playwright
+ shapes below are derived from it.
"""
- @abstractmethod
def get_playwright_proxy(self) -> dict[str, str] | None:
- """Return a Playwright proxy dict, or ``None`` when not configured."""
+ """Return a Playwright ``{"server","username","password"}`` dict, or ``None``.
+
+ Parsed from :meth:`get_proxy_url` (the canonical URL) by default, since
+ every provider's credentials already live in that URL. Override only for a
+ vendor whose Playwright form can't be expressed as a parse of the URL.
+ """
+ proxy_url = self.get_proxy_url()
+ if not proxy_url:
+ return None
+ parts = urlsplit(proxy_url)
+ if not parts.hostname:
+ return None
+ server = f"{parts.scheme or 'http'}://{parts.hostname}"
+ if parts.port:
+ server = f"{server}:{parts.port}"
+ proxy: dict[str, str] = {"server": server}
+ if parts.username:
+ proxy["username"] = parts.username
+ if parts.password:
+ proxy["password"] = parts.password
+ return proxy
def get_requests_proxies(self) -> dict[str, str] | None:
"""Return a ``requests``/``aiohttp`` proxies dict, or ``None``.
@@ -44,3 +66,26 @@ class ProxyProvider(ABC):
if proxy_url is None:
return None
return {"http": proxy_url, "https": proxy_url}
+
+ def get_location(self) -> str:
+ """Return the proxy's configured exit region (e.g. ``"us"``), or ``""``.
+
+ Vendor-agnostic hook the crawler's geoip-match (``03e``) uses to align the
+ browser locale/timezone with the exit IP's country. Default ``""``
+ (unknown) for providers that don't pin a region (e.g. BYO ``custom`` URLs,
+ where the region is baked opaquely into the URL). Override in providers
+ that hold the region as a discrete field.
+ """
+ return ""
+
+ @property
+ def is_pool_backed(self) -> bool:
+ """Whether this provider rotates across a *client-side* pool of endpoints.
+
+ ``False`` for single-endpoint providers (including server-side rotating
+ gateways like ``dataimpulse``, whose rotation happens upstream). The
+ crawler performs its bounded proxy-error rotation-retry **only** when this
+ is ``True`` — retrying a single static endpoint would just re-hit the same
+ dead proxy.
+ """
+ return False
diff --git a/surfsense_backend/app/utils/proxy/providers/anonymous_proxies.py b/surfsense_backend/app/utils/proxy/providers/anonymous_proxies.py
deleted file mode 100644
index a005a9e72..000000000
--- a/surfsense_backend/app/utils/proxy/providers/anonymous_proxies.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""anonymous-proxies.net residential / rotating proxy provider.
-
-The vendor (``rotating.dnsproxifier.com``) encodes the location and rotation
-``type`` options inside a base64-encoded JSON "password". The hostname already
-includes the port (e.g. ``rotating.dnsproxifier.com:31230``).
-"""
-
-import base64
-import json
-import logging
-
-from app.config import Config
-from app.utils.proxy.base import ProxyProvider
-
-logger = logging.getLogger(__name__)
-
-
-class AnonymousProxiesProvider(ProxyProvider):
- """Provider for anonymous-proxies.net credentials in ``RESIDENTIAL_PROXY_*``."""
-
- name = "anonymous_proxies"
-
- def _password_b64(self) -> str | None:
- """Build the base64-encoded password dict required by the vendor.
-
- Returns ``None`` when the password is not configured.
- """
- password = Config.RESIDENTIAL_PROXY_PASSWORD
- if not password:
- return None
-
- password_dict = {
- "p": password,
- "l": Config.RESIDENTIAL_PROXY_LOCATION,
- "t": Config.RESIDENTIAL_PROXY_TYPE,
- }
- return base64.b64encode(json.dumps(password_dict).encode("utf-8")).decode(
- "utf-8"
- )
-
- def get_proxy_url(self) -> str | None:
- username = Config.RESIDENTIAL_PROXY_USERNAME
- hostname = Config.RESIDENTIAL_PROXY_HOSTNAME
- password_b64 = self._password_b64()
-
- if not all([username, hostname, password_b64]):
- return None
-
- # No trailing slash: curl_cffi (Scrapling static fetcher) expects a bare
- # ``http://user:pass@host:port`` URL.
- return f"http://{username}:{password_b64}@{hostname}"
-
- def get_playwright_proxy(self) -> dict[str, str] | None:
- username = Config.RESIDENTIAL_PROXY_USERNAME
- hostname = Config.RESIDENTIAL_PROXY_HOSTNAME
- password_b64 = self._password_b64()
-
- if not all([username, hostname, password_b64]):
- return None
-
- return {
- "server": f"http://{hostname}",
- "username": username,
- "password": password_b64,
- }
diff --git a/surfsense_backend/app/utils/proxy/providers/custom.py b/surfsense_backend/app/utils/proxy/providers/custom.py
new file mode 100644
index 000000000..3dc55128e
--- /dev/null
+++ b/surfsense_backend/app/utils/proxy/providers/custom.py
@@ -0,0 +1,61 @@
+"""Bring-your-own (BYO) custom proxy provider.
+
+Reads one or more proxy endpoints from the shared env (``PROXY_URL`` and/or the
+comma-separated ``PROXY_URLS``). With a single endpoint it behaves like a static
+proxy; with a pool (>1) it rotates client-side via Scrapling's thread-safe
+``ProxyRotator`` (cyclic), transparently to every caller of the zero-arg getters.
+
+No vendor-specific auth assumptions: a user who wants a specific vendor points
+``PROXY_URLS`` at that vendor's ``http://user:pass@host:port`` endpoints.
+"""
+
+import logging
+
+from scrapling.engines.toolbelt import ProxyRotator
+
+from app.config import Config
+from app.utils.proxy.base import ProxyProvider
+
+logger = logging.getLogger(__name__)
+
+
+class CustomProxyProvider(ProxyProvider):
+ """BYO provider for a single endpoint or a rotating pool of endpoints."""
+
+ name = "custom"
+
+ def __init__(self) -> None:
+ self._urls = self._load_urls()
+ # Only build a rotator for an actual pool; a single endpoint stays static.
+ self._rotator = ProxyRotator(self._urls) if len(self._urls) > 1 else None
+ if not self._urls:
+ logger.warning(
+ "PROXY_PROVIDER='custom' selected but neither PROXY_URL nor "
+ "PROXY_URLS is set; crawls will run without a proxy."
+ )
+
+ @staticmethod
+ def _load_urls() -> list[str]:
+ """Collect proxy URLs from env (pool first, then single), de-duplicated."""
+ urls: list[str] = []
+ pool = Config.PROXY_URLS
+ if pool:
+ urls.extend(part.strip() for part in pool.split(",") if part.strip())
+
+ single = (Config.PROXY_URL or "").strip()
+ if single and single not in urls:
+ urls.append(single)
+
+ return urls
+
+ @property
+ def is_pool_backed(self) -> bool:
+ return self._rotator is not None
+
+ def get_proxy_url(self) -> str | None:
+ if not self._urls:
+ return None
+ if self._rotator is not None:
+ # Advances the cyclic index on every call (thread-safe).
+ return self._rotator.get_proxy()
+ return self._urls[0]
diff --git a/surfsense_backend/app/utils/proxy/providers/dataimpulse.py b/surfsense_backend/app/utils/proxy/providers/dataimpulse.py
new file mode 100644
index 000000000..17d09325f
--- /dev/null
+++ b/surfsense_backend/app/utils/proxy/providers/dataimpulse.py
@@ -0,0 +1,56 @@
+"""DataImpulse residential / rotating proxy provider.
+
+Takes the shared ``PROXY_URL`` env, exactly like the BYO ``custom`` provider —
+the format is uniform, paste it straight from the vendor dashboard. What makes
+this a *named* provider rather than just ``custom`` is the vendor-specific
+knowledge it layers on top of that URL:
+
+* :meth:`get_location` reads DataImpulse's ``__cr.`` username suffix so
+ the crawler's geoip-match can align the browser locale with the exit country
+ (``custom`` can't — it treats the URL as opaque).
+
+Rotation happens server-side (a fresh exit IP per request on the default pool
+port), so this is NOT :pyattr:`~ProxyProvider.is_pool_backed`.
+
+Example URL::
+
+ http://__cr.us:@gw.dataimpulse.com:823
+
+ponytail: sticky sessions (a stable exit IP across requests) are another
+username suffix (``__sid.``) — the lever the Reddit scraper's README flags as
+a TODO for its ``loid``-per-IP flow. Not built yet: Reddit isn't wired to a
+route, so there's no caller to thread a session id through. Add a
+``get_sticky_proxy_url(session_id)`` here (rewriting the username) when it lands.
+"""
+
+import logging
+from urllib.parse import urlsplit
+
+from app.config import Config
+from app.utils.proxy.base import ProxyProvider
+
+logger = logging.getLogger(__name__)
+
+# DataImpulse encodes country routing as a "__cr." username suffix; the
+# country token runs until the next "__" param (e.g. "__sid") or the end.
+_COUNTRY_MARKER = "__cr."
+
+
+class DataImpulseProvider(ProxyProvider):
+ """Provider for a DataImpulse proxy URL in the shared ``PROXY_URL`` env."""
+
+ name = "dataimpulse"
+
+ def get_proxy_url(self) -> str | None:
+ url = (Config.PROXY_URL or "").strip()
+ return url or None
+
+ def get_location(self) -> str:
+ """Country parsed from the ``__cr.`` username suffix, or ``""``."""
+ url = self.get_proxy_url()
+ if not url:
+ return ""
+ username = urlsplit(url).username or ""
+ if _COUNTRY_MARKER not in username:
+ return ""
+ return username.split(_COUNTRY_MARKER, 1)[1].split("__", 1)[0].lower()
diff --git a/surfsense_backend/app/utils/proxy/registry.py b/surfsense_backend/app/utils/proxy/registry.py
index 777dfc049..b222af83c 100644
--- a/surfsense_backend/app/utils/proxy/registry.py
+++ b/surfsense_backend/app/utils/proxy/registry.py
@@ -9,16 +9,20 @@ import logging
from app.config import Config
from app.utils.proxy.base import ProxyProvider
-from app.utils.proxy.providers.anonymous_proxies import AnonymousProxiesProvider
+from app.utils.proxy.providers.custom import CustomProxyProvider
+from app.utils.proxy.providers.dataimpulse import DataImpulseProvider
logger = logging.getLogger(__name__)
# Registered proxy providers, keyed by their ``name``.
_PROVIDERS: dict[str, type[ProxyProvider]] = {
- AnonymousProxiesProvider.name: AnonymousProxiesProvider,
+ CustomProxyProvider.name: CustomProxyProvider,
+ DataImpulseProvider.name: DataImpulseProvider,
}
-_DEFAULT_PROVIDER = AnonymousProxiesProvider.name
+# BYO ``custom`` is the neutral default: it needs no vendor and returns no proxy
+# until PROXY_URL(S) is set, so an unconfigured install simply runs direct.
+_DEFAULT_PROVIDER = CustomProxyProvider.name
_active_provider: ProxyProvider | None = None
diff --git a/surfsense_backend/app/utils/rbac.py b/surfsense_backend/app/utils/rbac.py
index c82c94344..3d425ab4b 100644
--- a/surfsense_backend/app/utils/rbac.py
+++ b/surfsense_backend/app/utils/rbac.py
@@ -1,6 +1,6 @@
"""
RBAC (Role-Based Access Control) utility functions.
-Provides helpers for checking user permissions in search spaces.
+Provides helpers for checking user permissions in workspaces.
"""
import secrets
@@ -14,9 +14,9 @@ from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
from app.db import (
Permission,
- SearchSpace,
- SearchSpaceMembership,
- SearchSpaceRole,
+ Workspace,
+ WorkspaceMembership,
+ WorkspaceRole,
has_permission,
)
@@ -24,25 +24,25 @@ from app.db import (
async def get_user_membership(
session: AsyncSession,
user_id: UUID,
- search_space_id: int,
-) -> SearchSpaceMembership | None:
+ workspace_id: int,
+) -> WorkspaceMembership | None:
"""
- Get the user's membership in a search space.
+ Get the user's membership in a workspace.
Args:
session: Database session
user_id: User UUID
- search_space_id: Search space ID
+ workspace_id: Workspace ID
Returns:
- SearchSpaceMembership if found, None otherwise
+ WorkspaceMembership if found, None otherwise
"""
result = await session.execute(
- select(SearchSpaceMembership)
- .options(selectinload(SearchSpaceMembership.role))
+ select(WorkspaceMembership)
+ .options(selectinload(WorkspaceMembership.role))
.filter(
- SearchSpaceMembership.user_id == user_id,
- SearchSpaceMembership.search_space_id == search_space_id,
+ WorkspaceMembership.user_id == user_id,
+ WorkspaceMembership.workspace_id == workspace_id,
)
)
return result.scalars().first()
@@ -51,20 +51,20 @@ async def get_user_membership(
async def get_user_permissions(
session: AsyncSession,
user_id: UUID,
- search_space_id: int,
+ workspace_id: int,
) -> list[str]:
"""
- Get the user's permissions in a search space.
+ Get the user's permissions in a workspace.
Args:
session: Database session
user_id: User UUID
- search_space_id: Search space ID
+ workspace_id: Workspace ID
Returns:
List of permission strings
"""
- membership = await get_user_membership(session, user_id, search_space_id)
+ membership = await get_user_membership(session, user_id, workspace_id)
if not membership:
return []
@@ -84,19 +84,19 @@ async def get_allowed_read_space_ids(
session: AsyncSession,
auth: AuthContext,
) -> list[int]:
- """Return search spaces the principal may read through sync transports.
+ """Return workspaces the principal may read through sync transports.
- This mirrors the basic REST search-space access rule: membership is required,
+ This mirrors the basic REST workspace access rule: membership is required,
and PAT principals are additionally constrained by the per-space API gate.
"""
stmt = (
- select(SearchSpaceMembership.search_space_id)
- .join(SearchSpace, SearchSpace.id == SearchSpaceMembership.search_space_id)
- .filter(SearchSpaceMembership.user_id == auth.user.id)
- .order_by(SearchSpaceMembership.search_space_id)
+ select(WorkspaceMembership.workspace_id)
+ .join(Workspace, Workspace.id == WorkspaceMembership.workspace_id)
+ .filter(WorkspaceMembership.user_id == auth.user.id)
+ .order_by(WorkspaceMembership.workspace_id)
)
if auth.is_gated:
- stmt = stmt.filter(SearchSpace.api_access_enabled == True) # noqa: E712
+ stmt = stmt.filter(Workspace.api_access_enabled == True) # noqa: E712
result = await session.execute(stmt)
return list(result.scalars().all())
@@ -105,57 +105,57 @@ async def get_allowed_read_space_ids(
async def _enforce_api_access_gate(
session: AsyncSession,
auth: AuthContext,
- search_space_id: int,
- search_space: SearchSpace | None = None,
-) -> SearchSpace:
- if search_space is None:
+ workspace_id: int,
+ workspace: Workspace | None = None,
+) -> Workspace:
+ if workspace is None:
result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == search_space_id)
+ select(Workspace).filter(Workspace.id == workspace_id)
)
- search_space = result.scalars().first()
+ workspace = result.scalars().first()
- if not search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
- if auth.is_gated and not search_space.api_access_enabled:
+ if auth.is_gated and not workspace.api_access_enabled:
raise HTTPException(
status_code=403,
- detail="API access is not enabled for this search space.",
+ detail="API access is not enabled for this workspace.",
)
- return search_space
+ return workspace
async def check_permission(
session: AsyncSession,
auth: AuthContext,
- search_space_id: int,
+ workspace_id: int,
required_permission: str,
error_message: str = "You don't have permission to perform this action",
-) -> SearchSpaceMembership:
+) -> WorkspaceMembership:
"""
- Check if a user has a specific permission in a search space.
+ Check if a user has a specific permission in a workspace.
Raises HTTPException if permission is denied.
Args:
session: Database session
user: User object
- search_space_id: Search space ID
+ workspace_id: Workspace ID
required_permission: Permission string to check
error_message: Custom error message for permission denied
Returns:
- SearchSpaceMembership if permission granted
+ WorkspaceMembership if permission granted
Raises:
HTTPException: If user doesn't have access or permission
"""
- membership = await get_user_membership(session, auth.user.id, search_space_id)
+ membership = await get_user_membership(session, auth.user.id, workspace_id)
if not membership:
raise HTTPException(
status_code=403,
- detail="You don't have access to this search space",
+ detail="You don't have access to this workspace",
)
# Get user's permissions
@@ -169,110 +169,110 @@ async def check_permission(
if not has_permission(permissions, required_permission):
raise HTTPException(status_code=403, detail=error_message)
- await _enforce_api_access_gate(session, auth, search_space_id)
+ await _enforce_api_access_gate(session, auth, workspace_id)
return membership
-async def check_search_space_access(
+async def check_workspace_access(
session: AsyncSession,
auth: AuthContext,
- search_space_id: int,
-) -> SearchSpaceMembership:
+ workspace_id: int,
+) -> WorkspaceMembership:
"""
- Check if a user has any access to a search space.
+ Check if a user has any access to a workspace.
This is used for basic access control (user is a member).
Args:
session: Database session
user: User object
- search_space_id: Search space ID
+ workspace_id: Workspace ID
Returns:
- SearchSpaceMembership if user has access
+ WorkspaceMembership if user has access
Raises:
HTTPException: If user doesn't have access
"""
- membership = await get_user_membership(session, auth.user.id, search_space_id)
+ membership = await get_user_membership(session, auth.user.id, workspace_id)
if not membership:
raise HTTPException(
status_code=403,
- detail="You don't have access to this search space",
+ detail="You don't have access to this workspace",
)
- await _enforce_api_access_gate(session, auth, search_space_id)
+ await _enforce_api_access_gate(session, auth, workspace_id)
return membership
-async def is_search_space_owner(
+async def is_workspace_owner(
session: AsyncSession,
user_id: UUID,
- search_space_id: int,
+ workspace_id: int,
) -> bool:
"""
- Check if a user is the owner of a search space.
+ Check if a user is the owner of a workspace.
Args:
session: Database session
user_id: User UUID
- search_space_id: Search space ID
+ workspace_id: Workspace ID
Returns:
True if user is the owner, False otherwise
"""
- membership = await get_user_membership(session, user_id, search_space_id)
+ membership = await get_user_membership(session, user_id, workspace_id)
return membership is not None and membership.is_owner
-async def get_search_space_with_access_check(
+async def get_workspace_with_access_check(
session: AsyncSession,
auth: AuthContext,
- search_space_id: int,
+ workspace_id: int,
required_permission: str | None = None,
-) -> tuple[SearchSpace, SearchSpaceMembership]:
+) -> tuple[Workspace, WorkspaceMembership]:
"""
- Get a search space with access and optional permission check.
+ Get a workspace with access and optional permission check.
Args:
session: Database session
user: User object
- search_space_id: Search space ID
+ workspace_id: Workspace ID
required_permission: Optional permission to check
Returns:
- Tuple of (SearchSpace, SearchSpaceMembership)
+ Tuple of (Workspace, WorkspaceMembership)
Raises:
- HTTPException: If search space not found or user lacks access/permission
+ HTTPException: If workspace not found or user lacks access/permission
"""
- # Get the search space
+ # Get the workspace
result = await session.execute(
- select(SearchSpace).filter(SearchSpace.id == search_space_id)
+ select(Workspace).filter(Workspace.id == workspace_id)
)
- search_space = result.scalars().first()
+ workspace = result.scalars().first()
- if not search_space:
- raise HTTPException(status_code=404, detail="Search space not found")
+ if not workspace:
+ raise HTTPException(status_code=404, detail="Workspace not found")
# Check access
if required_permission:
membership = await check_permission(
- session, auth, search_space_id, required_permission
+ session, auth, workspace_id, required_permission
)
else:
- membership = await check_search_space_access(session, auth, search_space_id)
+ membership = await check_workspace_access(session, auth, workspace_id)
- await _enforce_api_access_gate(session, auth, search_space_id, search_space)
+ await _enforce_api_access_gate(session, auth, workspace_id, workspace)
- return search_space, membership
+ return workspace, membership
def generate_invite_code() -> str:
"""
- Generate a unique invite code for search space invites.
+ Generate a unique invite code for workspace invites.
Returns:
A 32-character URL-safe invite code
@@ -282,22 +282,22 @@ def generate_invite_code() -> str:
async def get_default_role(
session: AsyncSession,
- search_space_id: int,
-) -> SearchSpaceRole | None:
+ workspace_id: int,
+) -> WorkspaceRole | None:
"""
- Get the default role for a search space (used when accepting invites without a specific role).
+ Get the default role for a workspace (used when accepting invites without a specific role).
Args:
session: Database session
- search_space_id: Search space ID
+ workspace_id: Workspace ID
Returns:
- Default SearchSpaceRole or None
+ Default WorkspaceRole or None
"""
result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.search_space_id == search_space_id,
- SearchSpaceRole.is_default == True, # noqa: E712
+ select(WorkspaceRole).filter(
+ WorkspaceRole.workspace_id == workspace_id,
+ WorkspaceRole.is_default == True, # noqa: E712
)
)
return result.scalars().first()
@@ -305,22 +305,22 @@ async def get_default_role(
async def get_owner_role(
session: AsyncSession,
- search_space_id: int,
-) -> SearchSpaceRole | None:
+ workspace_id: int,
+) -> WorkspaceRole | None:
"""
- Get the Owner role for a search space.
+ Get the Owner role for a workspace.
Args:
session: Database session
- search_space_id: Search space ID
+ workspace_id: Workspace ID
Returns:
- Owner SearchSpaceRole or None
+ Owner WorkspaceRole or None
"""
result = await session.execute(
- select(SearchSpaceRole).filter(
- SearchSpaceRole.search_space_id == search_space_id,
- SearchSpaceRole.name == "Owner",
+ select(WorkspaceRole).filter(
+ WorkspaceRole.workspace_id == workspace_id,
+ WorkspaceRole.name == "Owner",
)
)
return result.scalars().first()
diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py
index 6a87679ec..98bf8aa85 100644
--- a/surfsense_backend/app/utils/validators.py
+++ b/surfsense_backend/app/utils/validators.py
@@ -12,60 +12,110 @@ from typing import Any
import validators
from fastapi import HTTPException
+# Connectors retired during the MCP migration: no viable official MCP server
+# exists yet, so new connections are refused. Existing rows keep working until
+# the user removes them. If demand returns, reinstate the connector by dropping
+# it from this set and re-enabling its subagent/route.
+#
+# The four search APIs (TAVILY/SEARXNG/LINKUP/BAIDU) are deprecated alongside the
+# Google-only web-search consolidation: public web search now runs through the
+# google_search subagent, and users who still want Tavily/Linkup can add them via
+# the generic Custom MCP connector (API-key headers).
+DEPRECATED_CONNECTOR_TYPES: frozenset[str] = frozenset(
+ {
+ "DISCORD_CONNECTOR",
+ "TEAMS_CONNECTOR",
+ "LUMA_CONNECTOR",
+ "TAVILY_API",
+ "SEARXNG_API",
+ "LINKUP_API",
+ "BAIDU_SEARCH_API",
+ # Legacy content crawlers/search superseded by the file Import menu and
+ # hosted MCP tooling. Created via the generic connector /add route, which
+ # is the single choke point enforcing this deprecation.
+ "YOUTUBE_CONNECTOR",
+ "WEBCRAWLER_CONNECTOR",
+ "ELASTICSEARCH_CONNECTOR",
+ }
+)
-def validate_search_space_id(search_space_id: Any) -> int:
+
+def raise_if_connector_deprecated(connector_type: str | Any) -> None:
+ """Refuse new connections for a deprecated connector type (HTTP 410 Gone)."""
+ connector_type_str = (
+ connector_type.value
+ if hasattr(connector_type, "value")
+ else str(connector_type)
+ )
+ if connector_type_str in DEPRECATED_CONNECTOR_TYPES:
+ pretty = (
+ connector_type_str.replace("_CONNECTOR", "")
+ .replace("_SEARCH_API", "")
+ .replace("_API", "")
+ .replace("_", " ")
+ .title()
+ )
+ raise HTTPException(
+ status_code=410,
+ detail=(
+ f"The {pretty} connector has been deprecated and can no longer be "
+ "connected. If you were relying on it heavily, let us know and we'll "
+ "consider bringing it back."
+ ),
+ )
+
+
+def validate_workspace_id(workspace_id: Any) -> int:
"""
- Validate and convert search_space_id to integer.
+ Validate and convert workspace_id to integer.
Args:
- search_space_id: The search space ID to validate
+ workspace_id: The workspace ID to validate
Returns:
- int: Validated search space ID
+ int: Validated workspace ID
Raises:
HTTPException: If validation fails
"""
- if search_space_id is None:
- raise HTTPException(status_code=400, detail="search_space_id is required")
+ if workspace_id is None:
+ raise HTTPException(status_code=400, detail="workspace_id is required")
- if isinstance(search_space_id, bool):
+ if isinstance(workspace_id, bool):
raise HTTPException(
- status_code=400, detail="search_space_id must be an integer, not a boolean"
+ status_code=400, detail="workspace_id must be an integer, not a boolean"
)
- if isinstance(search_space_id, int):
- if search_space_id <= 0:
+ if isinstance(workspace_id, int):
+ if workspace_id <= 0:
raise HTTPException(
- status_code=400, detail="search_space_id must be a positive integer"
+ status_code=400, detail="workspace_id must be a positive integer"
)
- return search_space_id
+ return workspace_id
- if isinstance(search_space_id, str):
+ if isinstance(workspace_id, str):
# Check if it's a valid integer string
- if not search_space_id.strip():
- raise HTTPException(
- status_code=400, detail="search_space_id cannot be empty"
- )
+ if not workspace_id.strip():
+ raise HTTPException(status_code=400, detail="workspace_id cannot be empty")
# Check for valid integer format (no leading zeros, no decimal points)
- if not re.match(r"^[1-9]\d*$", search_space_id.strip()):
+ if not re.match(r"^[1-9]\d*$", workspace_id.strip()):
raise HTTPException(
status_code=400,
- detail="search_space_id must be a valid positive integer",
+ detail="workspace_id must be a valid positive integer",
)
- value = int(search_space_id.strip())
+ value = int(workspace_id.strip())
# Regex already guarantees value > 0, but check retained for clarity
if value <= 0:
raise HTTPException(
- status_code=400, detail="search_space_id must be a positive integer"
+ status_code=400, detail="workspace_id must be a positive integer"
)
return value
raise HTTPException(
status_code=400,
- detail="search_space_id must be an integer or string representation of an integer",
+ detail="workspace_id must be an integer or string representation of an integer",
)
@@ -469,22 +519,6 @@ def validate_connector_config(
if not isinstance(value, list) or not value:
raise ValueError(f"{field_name} must be a non-empty list of strings")
- def validate_firecrawl_api_key_format() -> None:
- """Validate Firecrawl API key format if provided."""
- api_key = config.get("FIRECRAWL_API_KEY", "")
- if api_key and api_key.strip() and not api_key.strip().startswith("fc-"):
- raise ValueError(
- "Firecrawl API key should start with 'fc-'. Please verify your API key."
- )
-
- def validate_initial_urls() -> None:
- initial_urls = config.get("INITIAL_URLS", "")
- if initial_urls and initial_urls.strip():
- urls = [url.strip() for url in initial_urls.split("\n") if url.strip()]
- for url in urls:
- if not validators.url(url):
- raise ValueError(f"Invalid URL format in INITIAL_URLS: {url}")
-
# Lookup table for connector validation rules
connector_rules = {
"SERPER_API": {"required": ["SERPER_API_KEY"], "validators": {}},
@@ -570,14 +604,6 @@ def validate_connector_config(
# "validators": {}
# },
"LUMA_CONNECTOR": {"required": ["LUMA_API_KEY"], "validators": {}},
- "WEBCRAWLER_CONNECTOR": {
- "required": [], # No required fields - API key is optional
- "optional": ["FIRECRAWL_API_KEY", "INITIAL_URLS"],
- "validators": {
- "FIRECRAWL_API_KEY": lambda: validate_firecrawl_api_key_format(),
- "INITIAL_URLS": lambda: validate_initial_urls(),
- },
- },
}
rules = connector_rules.get(connector_type_str)
diff --git a/surfsense_backend/app/utils/webcrawler_utils.py b/surfsense_backend/app/utils/webcrawler_utils.py
deleted file mode 100644
index 31d2ebe50..000000000
--- a/surfsense_backend/app/utils/webcrawler_utils.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""
-Utility functions for webcrawler connector.
-"""
-
-
-def parse_webcrawler_urls(initial_urls: str | list | None) -> list[str]:
- """
- Parse URLs from webcrawler INITIAL_URLS value.
-
- Handles both string (newline-separated) and list formats.
-
- Args:
- initial_urls: The INITIAL_URLS value (string, list, or None)
-
- Returns:
- List of parsed, stripped, non-empty URLs
- """
- if initial_urls is None:
- return []
-
- if isinstance(initial_urls, str):
- return [url.strip() for url in initial_urls.split("\n") if url.strip()]
- elif isinstance(initial_urls, list):
- return [
- url.strip() for url in initial_urls if isinstance(url, str) and url.strip()
- ]
- else:
- return []
diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py
index c16f27087..c44a29dcd 100644
--- a/surfsense_backend/app/zero_publication.py
+++ b/surfsense_backend/app/zero_publication.py
@@ -28,7 +28,7 @@ DOCUMENT_COLS = [
"id",
"title",
"document_type",
- "search_space_id",
+ "workspace_id",
"folder_id",
"created_by_id",
"status",
@@ -54,12 +54,12 @@ AUTOMATION_RUN_COLS = [
AUTOMATION_COLS = [
"id",
- "search_space_id",
+ "workspace_id",
]
NEW_CHAT_THREAD_COLS = [
"id",
- "search_space_id",
+ "workspace_id",
]
# Enough to drive the lifecycle UI by push: status, the reviewable brief, and
@@ -73,7 +73,7 @@ PODCAST_COLS = [
"spec_version",
"duration_seconds",
"error",
- "search_space_id",
+ "workspace_id",
"thread_id",
"created_at",
]
@@ -173,6 +173,37 @@ def apply_publication(conn: Connection) -> None:
conn.execute(text(build_set_table_sql(conn)))
+def ensure_publication(conn: Connection) -> None:
+ """Create ``zero_publication`` if missing, then reconcile if its shape drifted.
+
+ Startup-bootstrap counterpart of migration 116: databases created via
+ ``Base.metadata.create_all`` (dev/test, ``DB_BOOTSTRAP_ON_STARTUP=TRUE``)
+ never run migrations, so without this zero-cache crash-loops on
+ ``Unknown or invalid publications``. Idempotent: when the publication
+ already matches the canonical shape no DDL is emitted, so a normal boot
+ fires no event triggers and never disturbs a running zero-cache.
+ """
+
+ exists = conn.execute(
+ text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
+ {"name": PUBLICATION_NAME},
+ ).fetchone()
+ if not exists:
+ # Seed with one table; the reconcile below sets the full canonical
+ # shape. CREATE PUBLICATION is safe here (unlike in migrations, see
+ # 116_create_zero_publication.py): the publication does not exist, so
+ # no zero-cache replica can be attached to it yet.
+ conn.execute(
+ text(
+ f"CREATE PUBLICATION {_quote_identifier(PUBLICATION_NAME)} "
+ "FOR TABLE notifications"
+ )
+ )
+
+ if verify_publication(conn):
+ conn.execute(text(build_set_table_sql(conn)))
+
+
def _actual_publication_shape(conn: Connection) -> dict[str, list[str] | None]:
rows = conn.execute(
text(
diff --git a/surfsense_backend/main.py b/surfsense_backend/main.py
index 54911a34d..de7b0dace 100644
--- a/surfsense_backend/main.py
+++ b/surfsense_backend/main.py
@@ -6,10 +6,6 @@ import sys
import uvicorn
from dotenv import load_dotenv
-# Fix for Windows: psycopg requires SelectorEventLoop, not ProactorEventLoop
-if sys.platform == "win32":
- asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
-
from app.config.uvicorn import load_uvicorn_config
_old_log_record_factory = logging.getLogRecordFactory()
@@ -47,6 +43,14 @@ if __name__ == "__main__":
server = uvicorn.Server(config)
if sys.platform == "win32":
+ # Windows needs a split-loop setup: psycopg's async driver requires a
+ # SelectorEventLoop (no add_reader on Proactor), so the SERVER loop is
+ # forced to Selector via loop_factory. The global policy is left at
+ # its default (Proactor) so worker threads that call
+ # asyncio.new_event_loop() — playwright/patchright sync API used by
+ # the scrapers, unstructured, etc. — get a loop that CAN spawn
+ # subprocesses. Do not set WindowsSelectorEventLoopPolicy globally:
+ # that breaks every browser-based scraper with NotImplementedError.
asyncio.run(server.serve(), loop_factory=asyncio.SelectorEventLoop)
else:
server.run()
diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml
index 8c9e96852..2faa2be17 100644
--- a/surfsense_backend/pyproject.toml
+++ b/surfsense_backend/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "surf-new-backend"
-version = "0.0.30"
+version = "0.0.31"
description = "SurfSense Backend"
requires-python = ">=3.12"
dependencies = [
@@ -18,7 +18,6 @@ dependencies = [
"google-api-python-client>=2.156.0",
"google-auth-oauthlib>=1.2.1",
"kokoro>=0.9.4",
- "linkup-sdk>=0.2.4",
"llama-cloud-services>=0.6.25",
"Markdown>=3.7",
"markdownify>=0.14.1",
@@ -34,7 +33,6 @@ dependencies = [
"spacy>=3.8.7",
"en-core-web-sm@https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl",
"static-ffmpeg>=2.13",
- "tavily-python>=0.3.2",
"uvicorn[standard]>=0.34.0",
"validators>=0.34.0",
"youtube-transcript-api>=1.0.3",
@@ -42,11 +40,11 @@ dependencies = [
"faster-whisper>=1.1.0",
"celery[redis]>=5.5.3",
"redis>=5.2.1",
- "firecrawl-py>=4.9.0",
"boto3>=1.35.0",
"azure-storage-blob>=12.23.0",
"fake-useragent>=2.2.0",
"trafilatura>=2.0.0",
+ "captchatools>=1.5.0",
"fastapi-users[oauth,sqlalchemy]>=15.0.3",
"chonkie[all]>=1.5.0",
"langgraph-checkpoint-postgres>=3.0.2",
diff --git a/surfsense_backend/scripts/check_migration_flow.py b/surfsense_backend/scripts/check_migration_flow.py
new file mode 100644
index 000000000..155e5ac84
--- /dev/null
+++ b/surfsense_backend/scripts/check_migration_flow.py
@@ -0,0 +1,142 @@
+"""Self-check for the alembic fast-forward/adoption flow in alembic/env.py.
+
+Verifies ``alembic upgrade head`` succeeds on the three DB states it must
+handle without replaying pre-workspace-rename history against a
+workspace-shape schema:
+
+ 1. fresh -- empty database (fast-forward: create_all + stamp head)
+ 2. bootstrap -- create_all-created schema + zero_publication, no alembic
+ history (adoption: stamp head)
+ 3. midcrash -- bootstrap schema whose alembic_version is stuck at a
+ pre-rename revision from a failed replay (adoption: stamp head)
+
+Run: python scripts/check_migration_flow.py
+"""
+
+import asyncio
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+BACKEND_DIR = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND_DIR))
+
+ADMIN_URL = os.getenv(
+ "ADMIN_DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/postgres"
+)
+SCRATCH_DB = "surfsense_check_migration_flow"
+SCRATCH_URL = ADMIN_URL.rsplit("/", 1)[0] + f"/{SCRATCH_DB}"
+SCRATCH_URL_ASYNC = SCRATCH_URL.replace("postgresql://", "postgresql+asyncpg://")
+
+
+async def recreate_scratch_db() -> None:
+ import asyncpg
+
+ admin = await asyncpg.connect(ADMIN_URL)
+ await admin.execute(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')
+ await admin.execute(f'CREATE DATABASE "{SCRATCH_DB}"')
+ await admin.close()
+
+
+async def drop_scratch_db() -> None:
+ import asyncpg
+
+ admin = await asyncpg.connect(ADMIN_URL)
+ await admin.execute(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')
+ await admin.close()
+
+
+def run_alembic_upgrade() -> None:
+ env = dict(os.environ, DATABASE_URL=SCRATCH_URL_ASYNC)
+ subprocess.run(
+ [sys.executable, "-m", "alembic", "upgrade", "head"],
+ cwd=BACKEND_DIR,
+ env=env,
+ check=True,
+ )
+
+
+async def bootstrap_schema() -> None:
+ """Mimic app startup bootstrap: create_all + ensure_publication, no stamp."""
+ from sqlalchemy import text
+ from sqlalchemy.ext.asyncio import create_async_engine
+
+ from app.db import Base
+ from app.zero_publication import ensure_publication
+
+ engine = create_async_engine(SCRATCH_URL_ASYNC)
+ async with engine.begin() as conn:
+ await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
+ await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
+ await conn.run_sync(Base.metadata.create_all)
+ await conn.run_sync(ensure_publication)
+ await engine.dispose()
+
+
+async def set_version(version: str | None) -> None:
+ import asyncpg
+
+ conn = await asyncpg.connect(SCRATCH_URL)
+ if version is None:
+ await conn.execute("DROP TABLE IF EXISTS alembic_version")
+ else:
+ await conn.execute(
+ "CREATE TABLE IF NOT EXISTS alembic_version ("
+ "version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
+ )
+ await conn.execute("DELETE FROM alembic_version")
+ await conn.execute(
+ "INSERT INTO alembic_version (version_num) VALUES ($1)", version
+ )
+ await conn.close()
+
+
+async def assert_at_head() -> None:
+ import asyncpg
+ from alembic.script import ScriptDirectory
+
+ head = ScriptDirectory(str(BACKEND_DIR / "alembic")).get_current_head()
+ conn = await asyncpg.connect(SCRATCH_URL)
+ version = await conn.fetchval("SELECT version_num FROM alembic_version")
+ workspaces = await conn.fetchval("SELECT to_regclass('workspaces')")
+ publication = await conn.fetchval(
+ "SELECT 1 FROM pg_publication WHERE pubname = 'zero_publication'"
+ )
+ await conn.close()
+ assert version == head, f"expected version {head}, got {version}"
+ assert workspaces, "workspaces table missing"
+ assert publication, "zero_publication missing"
+
+
+async def main() -> None:
+ try:
+ # 1. Fresh empty DB -> fast-forward.
+ await recreate_scratch_db()
+ run_alembic_upgrade()
+ await assert_at_head()
+ print("OK: fresh DB fast-forwards to head")
+
+ # 2. Bootstrap-created schema, no alembic history -> adoption.
+ await recreate_scratch_db()
+ await bootstrap_schema()
+ await set_version(None)
+ run_alembic_upgrade()
+ await assert_at_head()
+ print("OK: bootstrap-created schema adopted (stamped head)")
+
+ # 3. Bootstrap schema stuck at a pre-rename revision -> adoption.
+ await set_version("4")
+ run_alembic_upgrade()
+ await assert_at_head()
+ print("OK: pre-rename stuck revision adopted (stamped head)")
+
+ # Re-run must be a clean no-op.
+ run_alembic_upgrade()
+ await assert_at_head()
+ print("OK: repeat upgrade is a no-op")
+ finally:
+ await drop_scratch_db()
+
+
+asyncio.run(main())
diff --git a/surfsense_backend/scripts/check_zero_publication_bootstrap.py b/surfsense_backend/scripts/check_zero_publication_bootstrap.py
new file mode 100644
index 000000000..3738901c1
--- /dev/null
+++ b/surfsense_backend/scripts/check_zero_publication_bootstrap.py
@@ -0,0 +1,48 @@
+"""Self-check for ensure_publication on a create_all-bootstrapped scratch DB."""
+
+import asyncio
+
+import asyncpg
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+SCRATCH_DB = "surfsense_zero_pub_check"
+ADMIN_DSN = "postgresql://postgres:postgres@localhost:5432/postgres"
+SCRATCH_URL = f"postgresql+asyncpg://postgres:postgres@localhost:5432/{SCRATCH_DB}"
+
+
+async def main() -> None:
+ admin = await asyncpg.connect(ADMIN_DSN)
+ await admin.execute(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')
+ await admin.execute(f'CREATE DATABASE "{SCRATCH_DB}"')
+ await admin.close()
+
+ from app.db import Base
+ from app.zero_publication import ensure_publication, verify_publication
+
+ engine = create_async_engine(SCRATCH_URL)
+ try:
+ async with engine.begin() as conn:
+ await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
+ await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
+ await conn.run_sync(Base.metadata.create_all)
+ await conn.run_sync(ensure_publication)
+ mismatches = await conn.run_sync(verify_publication)
+ assert not mismatches, f"shape wrong after ensure: {mismatches}"
+
+ # Second call must be a no-op that leaves a verified shape.
+ await conn.run_sync(ensure_publication)
+ mismatches = await conn.run_sync(verify_publication)
+ assert not mismatches, f"shape wrong after re-ensure: {mismatches}"
+ finally:
+ await engine.dispose()
+ admin = await asyncpg.connect(ADMIN_DSN)
+ await admin.execute(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')
+ await admin.close()
+
+ print(
+ "OK: ensure_publication creates and verifies on a create_all DB, idempotently."
+ )
+
+
+asyncio.run(main())
diff --git a/surfsense_backend/scripts/e2e_google_maps_deep.py b/surfsense_backend/scripts/e2e_google_maps_deep.py
new file mode 100644
index 000000000..efaf4f588
--- /dev/null
+++ b/surfsense_backend/scripts/e2e_google_maps_deep.py
@@ -0,0 +1,824 @@
+"""Deep functional verification for the Google Maps scraper (live network).
+
+Complements the fast smoke e2e (e2e_google_maps_scraper.py) with breadth:
+diverse places (countries, scripts, categories), URL-kind coverage (name-only
+URLs, CID), and review semantics (sort order, date cutoff, pagination
+uniqueness, personal-data stripping, localization).
+
+Verification style: ground-truth invariants instead of screenshots — known
+coordinates/websites/address keywords for world-famous places, and internal
+consistency rules (newest sort is monotonically non-increasing, cutoff dates
+hold, review IDs are unique across pages, etc.).
+
+Run from the backend directory:
+ .\\.venv\\Scripts\\python.exe scripts/e2e_google_maps_deep.py
+"""
+
+import asyncio
+import itertools
+import sys
+from datetime import datetime
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_BACKEND_ROOT))
+for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
+ if _candidate.exists():
+ load_dotenv(_candidate)
+ break
+
+from app.proprietary.platforms.google_maps import ( # noqa: E402
+ GoogleMapsReviewsInput,
+ GoogleMapsScrapeInput,
+ scrape_places,
+ scrape_reviews,
+)
+
+_CHECKS: list[tuple[str, bool, str]] = []
+
+
+def _check(label: str, ok: bool, detail: str = "") -> bool:
+ _CHECKS.append((label, ok, detail))
+ print(f" [{'PASS' if ok else 'FAIL'}] {label}{f' — {detail}' if detail else ''}")
+ return ok
+
+
+def _hr(title: str) -> None:
+ print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
+
+
+def _near(actual: float | None, expected: float, tol: float = 0.05) -> bool:
+ return actual is not None and abs(actual - expected) <= tol
+
+
+def _iso(s: str | None) -> datetime | None:
+ if not s:
+ return None
+ try:
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+
+
+# Ground truth: world-famous places whose facts don't drift. Name-only URLs
+# exercise the HTML -> fid resolution path for every one of them.
+_PLACES = [
+ {
+ "label": "Eiffel Tower (FR landmark)",
+ "url": "https://www.google.com/maps/place/Eiffel+Tower/",
+ "title_contains": "eiffel",
+ "lat": 48.8584,
+ "lng": 2.2945,
+ "address_contains": ["paris", "75007", "france"],
+ "website_contains": "toureiffel",
+ },
+ {
+ "label": "Tokyo Tower (JP, non-Latin locale)",
+ "url": "https://www.google.com/maps/place/Tokyo+Tower/",
+ "title_contains": "tokyo tower",
+ "lat": 35.6586,
+ "lng": 139.7454,
+ "address_contains": ["tokyo", "japan", "minato"],
+ },
+ {
+ "label": "Sydney Opera House (AU)",
+ "url": "https://www.google.com/maps/place/Sydney+Opera+House/",
+ "title_contains": "opera house",
+ "lat": -33.8568,
+ "lng": 151.2153,
+ "address_contains": ["sydney", "nsw", "australia"],
+ },
+ {
+ "label": "The Plaza Hotel (US hotel category)",
+ "url": "https://www.google.com/maps/place/The+Plaza+Hotel+New+York/",
+ "title_contains": "plaza",
+ "lat": 40.7646,
+ "lng": -73.9744,
+ "address_contains": ["new york", "ny"],
+ "category_contains": "hotel",
+ },
+]
+
+_KIMS_CID_URL = "https://maps.google.com/?cid=7838756667406262025" # Kim's Island
+_KIMS_PLACE_ID = "ChIJJQz5EZzKw4kRCZ95UajbyGw"
+_LOUVRE_URL = "https://www.google.com/maps/place/Louvre+Museum/"
+
+
+async def scrape_one(url: str, **kwargs) -> dict | None:
+ items = await scrape_places(
+ GoogleMapsScrapeInput(startUrls=[{"url": url}], **kwargs)
+ )
+ return items[0] if items else None
+
+
+async def step_diverse_places() -> None:
+ _hr("A — diverse places via name-only URLs (HTML -> fid path)")
+ for spec in _PLACES:
+ it = await scrape_one(spec["url"])
+ if it is None:
+ _check(spec["label"], False, "no item returned")
+ continue
+ title = (it.get("title") or "").lower()
+ loc = it.get("location") or {}
+ addr = (it.get("address") or "").lower()
+ problems = []
+ if spec["title_contains"] not in title:
+ problems.append(f"title={it.get('title')!r}")
+ if not _near(loc.get("lat"), spec["lat"]) or not _near(
+ loc.get("lng"), spec["lng"]
+ ):
+ problems.append(f"loc={loc}")
+ if not any(k in addr for k in spec["address_contains"]):
+ problems.append(f"address={it.get('address')!r}")
+ if not it.get("placeId"):
+ problems.append("no placeId")
+ if not it.get("categories"):
+ problems.append("no categories")
+ if spec.get("website_contains") and spec["website_contains"] not in (
+ it.get("website") or ""
+ ):
+ problems.append(f"website={it.get('website')!r}")
+ if (
+ spec.get("category_contains")
+ and spec["category_contains"]
+ not in (
+ (it.get("categoryName") or "") + " ".join(it.get("categories") or [])
+ ).lower()
+ ):
+ problems.append(f"categories={it.get('categories')}")
+ _check(
+ spec["label"],
+ not problems,
+ "; ".join(problems)
+ or f"{it.get('title')!r} @ ({loc.get('lat'):.4f},{loc.get('lng'):.4f}) "
+ f"cat={it.get('categoryName')!r} score={it.get('totalScore')}",
+ )
+
+
+async def step_cid_url() -> None:
+ _hr("B — CID URL dispatch")
+ it = await scrape_one(_KIMS_CID_URL)
+ ok = it is not None and it.get("title") == "Kim's Island"
+ _check(
+ "?cid=... resolves to the right place",
+ ok,
+ f"title={it.get('title')!r}" if it else "no item",
+ )
+ if it:
+ _check(
+ "cid place has full detail (phone+hours)",
+ bool(it.get("phone")) and bool(it.get("openingHours")),
+ f"phone={it.get('phone')!r}, hours={len(it.get('openingHours') or [])} days",
+ )
+
+
+async def step_review_sorts() -> None:
+ _hr("C — review sort semantics (Kim's Island)")
+ newest = await scrape_reviews(
+ GoogleMapsReviewsInput(placeIds=[_KIMS_PLACE_ID], maxReviews=10)
+ )
+ dates = [_iso(r.get("publishedAtDate")) for r in newest]
+ dated = [d for d in dates if d]
+ _check(
+ "newest: publishedAtDate non-increasing",
+ len(dated) >= 5 and all(a >= b for a, b in itertools.pairwise(dated)),
+ f"{len(newest)} reviews, first={newest[0].get('publishAt') if newest else None}",
+ )
+
+ lowest = await scrape_reviews(
+ GoogleMapsReviewsInput(
+ placeIds=[_KIMS_PLACE_ID], maxReviews=10, reviewsSort="lowestRanking"
+ )
+ )
+ highest = await scrape_reviews(
+ GoogleMapsReviewsInput(
+ placeIds=[_KIMS_PLACE_ID], maxReviews=10, reviewsSort="highestRanking"
+ )
+ )
+ lo = [r["stars"] for r in lowest if r.get("stars")]
+ hi = [r["stars"] for r in highest if r.get("stars")]
+ lo_avg = sum(lo) / len(lo) if lo else 0
+ hi_avg = sum(hi) / len(hi) if hi else 0
+ _check(
+ "lowestRanking avg < highestRanking avg",
+ bool(lo and hi) and lo_avg < hi_avg,
+ f"lowest avg={lo_avg:.2f} (first={lo[:3]}), highest avg={hi_avg:.2f} (first={hi[:3]})",
+ )
+ _check(
+ "highestRanking page is all 5 stars",
+ bool(hi) and all(s == 5 for s in hi),
+ f"stars={hi}",
+ )
+
+
+async def step_start_date_cutoff() -> None:
+ _hr("D — reviewsStartDate cutoff")
+ baseline = await scrape_reviews(
+ GoogleMapsReviewsInput(placeIds=[_KIMS_PLACE_ID], maxReviews=10)
+ )
+ dated = [(_iso(r.get("publishedAtDate")), r) for r in baseline]
+ dated = [(d, r) for d, r in dated if d]
+ if len(dated) < 6:
+ _check("cutoff test has enough dated reviews", False, f"only {len(dated)}")
+ return
+ # Cut between the 4th and 5th newest review -> expect exactly 4 back.
+ cutoff_dt = dated[4][0]
+ cutoff = (
+ dated[3][0].strftime("%Y-%m-%dT%H:%M:%S.000Z")
+ if dated[3][0] == cutoff_dt
+ else cutoff_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
+ )
+ got = await scrape_reviews(
+ GoogleMapsReviewsInput(
+ placeIds=[_KIMS_PLACE_ID], maxReviews=100, reviewsStartDate=cutoff
+ )
+ )
+ got_dates = [_iso(r.get("publishedAtDate")) for r in got]
+ cutoff_parsed = _iso(cutoff)
+ _check(
+ "all returned reviews >= cutoff",
+ bool(got) and all(d is None or d >= cutoff_parsed for d in got_dates),
+ f"cutoff={cutoff}, returned={len(got)} (expected ~4)",
+ )
+ _check(
+ "cutoff actually limits the result",
+ 0 < len(got) < len(baseline) + 1 and len(got) <= 6,
+ f"{len(got)} vs baseline {len(baseline)}",
+ )
+
+
+async def step_personal_data() -> None:
+ _hr("E — personalData=false stripping")
+ items = await scrape_reviews(
+ GoogleMapsReviewsInput(
+ placeIds=[_KIMS_PLACE_ID], maxReviews=3, personalData=False
+ )
+ )
+ if not items:
+ _check("reviews returned", False)
+ return
+ leaked = [
+ k
+ for k in ("name", "reviewerId", "reviewerUrl", "reviewerPhotoUrl")
+ if any(r.get(k) for r in items)
+ ]
+ _check(
+ "reviewer identity fields are absent",
+ not leaked,
+ f"leaked={leaked}" if leaked else f"{len(items)} reviews, ids+stars kept",
+ )
+ _check(
+ "non-personal fields survive",
+ all(r.get("reviewId") and r.get("stars") for r in items),
+ )
+
+
+async def step_localization() -> None:
+ _hr("F — localization (language=fr)")
+ items = await scrape_reviews(
+ GoogleMapsReviewsInput(
+ startUrls=[{"url": "https://www.google.com/maps/place/Eiffel+Tower/"}],
+ maxReviews=5,
+ language="fr",
+ )
+ )
+ if not items:
+ _check("french reviews returned", False)
+ return
+ rel = [r.get("publishAt") or "" for r in items]
+ french = [s for s in rel if "il y a" in s or "mois" in s or "semaine" in s]
+ _check(
+ "relative dates come back in French",
+ len(french) >= 3,
+ f"publishAt={rel}",
+ )
+ _check(
+ "items stamped language=fr",
+ all(r.get("language") == "fr" for r in items),
+ )
+
+
+async def step_big_place_pagination() -> None:
+ _hr("G — big place, 30 reviews across >=3 pages (Louvre)")
+ items = await scrape_reviews(
+ GoogleMapsReviewsInput(startUrls=[{"url": _LOUVRE_URL}], maxReviews=30)
+ )
+ ids = [r.get("reviewId") for r in items]
+ _check(
+ "30 reviews with unique IDs",
+ len(items) == 30 and len(set(ids)) == 30,
+ f"{len(items)} reviews, {len(set(ids))} unique",
+ )
+ ok_fields = all(
+ r.get("name") and r.get("stars") is not None and r.get("publishedAtDate")
+ for r in items
+ )
+ _check("every review has author/stars/date", ok_fields)
+ _check(
+ "place header stamped on all (Louvre)",
+ all("louvre" in (r.get("title") or "").lower() for r in items),
+ f"title={items[0].get('title')!r}" if items else "",
+ )
+
+
+async def step_search_discovery() -> None:
+ _hr("I — search discovery: paging, rank, dedupe (pizza in New York)")
+ items = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["pizza"],
+ locationQuery="New York, NY",
+ maxCrawledPlacesPerSearch=25,
+ )
+ )
+ fids = [i.get("fid") for i in items]
+ _check(
+ "25 places across >1 page, all unique fids",
+ len(items) == 25 and len(set(fids)) == 25,
+ f"{len(items)} items, {len(set(fids))} unique",
+ )
+ _check(
+ "ranks are 1..25 in order",
+ [i.get("rank") for i in items] == list(range(1, 26)),
+ )
+ ny = sum(
+ 1
+ for i in items
+ if i.get("location")
+ and 40.4 < (i["location"]["lat"] or 0) < 41.1
+ and -74.3 < (i["location"]["lng"] or 0) < -73.6
+ )
+ _check(
+ "locationQuery scopes results to NYC",
+ ny >= 23,
+ f"{ny}/25 within NYC bounds",
+ )
+ with_core = sum(
+ 1 for i in items if i.get("title") and i.get("placeId") and i.get("address")
+ )
+ _check("all items have title/placeId/address", with_core == 25, f"{with_core}/25")
+
+
+async def step_search_filters() -> None:
+ _hr("J — search filters (stars, website, matching)")
+ starred = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["restaurant"],
+ locationQuery="Chicago, IL",
+ maxCrawledPlacesPerSearch=10,
+ placeMinimumStars="fourAndHalf",
+ )
+ )
+ scores = [i.get("totalScore") for i in starred]
+ _check(
+ "placeMinimumStars=fourAndHalf holds",
+ bool(scores) and all(s is not None and s >= 4.5 for s in scores),
+ f"scores={scores}",
+ )
+
+ no_web = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["restaurant"],
+ locationQuery="Chicago, IL",
+ maxCrawledPlacesPerSearch=5,
+ website="withoutWebsite",
+ )
+ )
+ _check(
+ "website=withoutWebsite holds",
+ bool(no_web) and all(not i.get("website") for i in no_web),
+ f"{len(no_web)} items, websites={[i.get('website') for i in no_web]}",
+ )
+
+ matching = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["pizza"],
+ locationQuery="Boston, MA",
+ maxCrawledPlacesPerSearch=5,
+ searchMatching="only_includes",
+ )
+ )
+ titles = [i.get("title") for i in matching]
+ _check(
+ "searchMatching=only_includes keeps 'pizza' in titles",
+ bool(titles) and all("pizza" in (t or "").lower() for t in titles),
+ f"titles={titles}",
+ )
+
+
+async def step_search_closed() -> None:
+ _hr("K — closed-place detection + skipClosedPlaces (Dean & DeLuca)")
+ q = "Dean DeLuca 560 Broadway New York"
+ found = await scrape_places(
+ GoogleMapsScrapeInput(searchStringsArray=[q], maxCrawledPlacesPerSearch=3)
+ )
+ dean = next((i for i in found if "DeLuca" in (i.get("title") or "")), None)
+ _check(
+ "permanently closed place flagged",
+ dean is not None and dean.get("permanentlyClosed") is True,
+ f"title={dean.get('title') if dean else None}, closed={dean.get('permanentlyClosed') if dean else None}",
+ )
+ skipped = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=[q],
+ maxCrawledPlacesPerSearch=3,
+ skipClosedPlaces=True,
+ )
+ )
+ _check(
+ "skipClosedPlaces filters it out",
+ not any("DeLuca" in (i.get("title") or "") for i in skipped),
+ f"{len(skipped)} items after skip",
+ )
+
+
+async def step_search_url_and_geo() -> None:
+ _hr("L — /maps/search/ URL routing + customGeolocation")
+ via_url = await scrape_places(
+ GoogleMapsScrapeInput(
+ startUrls=[
+ {"url": "https://www.google.com/maps/search/ramen+in+Osaka+Japan/"}
+ ],
+ maxCrawledPlacesPerSearch=5,
+ )
+ )
+ osaka = sum(
+ 1
+ for i in via_url
+ if i.get("location") and 34.4 < (i["location"]["lat"] or 0) < 35.0
+ )
+ _check(
+ "/maps/search/ startUrl yields Osaka ramen places",
+ len(via_url) == 5 and osaka >= 4,
+ f"{len(via_url)} items, {osaka} in Osaka",
+ )
+
+ geo = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["museum"],
+ customGeolocation={
+ "type": "Point",
+ "coordinates": [2.3522, 48.8566], # [lng, lat] Paris
+ "radiusKm": 10,
+ },
+ maxCrawledPlacesPerSearch=5,
+ )
+ )
+ paris = sum(
+ 1
+ for i in geo
+ if i.get("location")
+ and 48.5 < (i["location"]["lat"] or 0) < 49.2
+ and 1.9 < (i["location"]["lng"] or 0) < 2.8
+ )
+ _check(
+ "customGeolocation Point scopes to Paris",
+ len(geo) >= 3 and paris >= 3,
+ f"{len(geo)} items, {paris} in Paris: {[i.get('title') for i in geo]}",
+ )
+
+
+async def step_search_pagination_stress() -> None:
+ _hr("M — search pagination stress (60 results / 3+ pages, exhaustion)")
+ items = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["restaurant"],
+ locationQuery="Manhattan, New York",
+ maxCrawledPlacesPerSearch=60,
+ )
+ )
+ fids = [i.get("fid") for i in items]
+ _check(
+ "60 places, all fids unique (offset paging + dedupe)",
+ len(items) == 60 and len(set(fids)) == 60,
+ f"{len(items)} items, {len(set(fids))} unique",
+ )
+ _check(
+ "ranks strictly sequential 1..60",
+ [i.get("rank") for i in items] == list(range(1, 61)),
+ )
+ manhattan = sum(
+ 1
+ for i in items
+ if i.get("location") and 40.68 < (i["location"]["lat"] or 0) < 40.9
+ )
+ _check(
+ "results stay in Manhattan across pages",
+ manhattan >= 55,
+ f"{manhattan}/60 in bounds",
+ )
+
+ # A hyper-specific query has few results: paging must terminate on its
+ # own (no infinite loop, no error) well before the requested cap.
+ sparse = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["Kim's Island Staten Island"],
+ maxCrawledPlacesPerSearch=100,
+ )
+ )
+ _check(
+ "sparse query exhausts naturally below the cap",
+ 0 < len(sparse) < 25,
+ f"{len(sparse)} results",
+ )
+
+
+async def step_detail_extras() -> None:
+ _hr("N — detail-page extras (kgmid/cid/additionalInfo/links)")
+ # pin the exact place by fid — a name search returns a different Joe's
+ # branch run to run, which makes magnitude assertions flaky
+ items = await scrape_places(
+ GoogleMapsScrapeInput(
+ startUrls=[
+ {
+ "url": "https://www.google.com/maps/place/Joe's+Pizza+Broadway/"
+ "data=!4m2!3m1!1s0x89c259ab3c1ef289:0x3b67a41175949f55"
+ }
+ ],
+ maxImages=5,
+ )
+ )
+ if not items:
+ _check("place returned", False)
+ return
+ it = items[0]
+ dist = it.get("reviewsDistribution") or {}
+ _check(
+ "reviewsCount + distribution (NID-session fields)",
+ (it.get("reviewsCount") or 0) > 20_000
+ and (it.get("reviewsCount") == sum(dist.values())),
+ f"count={it.get('reviewsCount')}, dist={dist}",
+ )
+ hist = it.get("popularTimesHistogram") or {}
+ _check(
+ "popular times histogram covers the week",
+ set(hist) == {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}
+ and all(
+ {"hour", "occupancyPercent"} <= set(slot)
+ for d in hist.values()
+ for slot in d
+ ),
+ f"days={sorted(hist)}",
+ )
+ _check(
+ "image gallery fields (count/categories/urls capped at maxImages)",
+ (it.get("imagesCount") or 0) > 1000
+ and "All" in (it.get("imageCategories") or [])
+ and len(it.get("imageUrls") or []) == 5,
+ f"count={it.get('imagesCount')}, cats={len(it.get('imageCategories') or [])}, "
+ f"urls={len(it.get('imageUrls') or [])}",
+ )
+ tags = it.get("reviewsTags") or []
+ _check(
+ "reviewsTags with counts",
+ len(tags) >= 5 and all(t.get("title") and t.get("count") for t in tags),
+ f"{tags[:2]}",
+ )
+ _check(
+ "additionalInfo has full section set (not just Accessibility)",
+ len(it.get("additionalInfo") or {}) >= 8,
+ f"sections={list((it.get('additionalInfo') or {}).keys())}",
+ )
+
+ # scrapePlaceDetailPage on the SEARCH flow: search darrays lack the
+ # session-gated fields, so each hit must get enriched via a detail RPC.
+ enriched = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["ramen"],
+ locationQuery="Chicago, IL",
+ maxCrawledPlacesPerSearch=2,
+ scrapePlaceDetailPage=True,
+ )
+ )
+ _check(
+ "search flow + scrapePlaceDetailPage enriches every hit",
+ len(enriched) == 2
+ and all(
+ (e.get("reviewsCount") or 0) > 0 and e.get("reviewsDistribution")
+ for e in enriched
+ ),
+ f"counts={[e.get('reviewsCount') for e in enriched]}",
+ )
+ fid = it.get("fid") or ""
+ cid_ok = bool(fid) and it.get("cid") == str(int(fid.split(":")[1], 16))
+ _check(
+ "kgmid + cid derived from fid",
+ bool(it.get("kgmid", "").startswith("/g/")) and cid_ok,
+ f"kgmid={it.get('kgmid')}, cid={it.get('cid')}",
+ )
+ info = it.get("additionalInfo") or {}
+ _check(
+ "additionalInfo has sections with boolean options",
+ bool(info)
+ and all(
+ isinstance(v, list) and all(isinstance(e, dict) for e in v)
+ for v in info.values()
+ ),
+ f"sections={list(info.keys())}",
+ )
+
+
+async def step_hotel_fields() -> None:
+ _hr("O — hotel fields (The Plaza: stars, dates, similar, ads)")
+ items = await scrape_places(
+ GoogleMapsScrapeInput(
+ startUrls=[
+ {
+ "url": "https://www.google.com/maps/place/The+Plaza/"
+ "data=!4m2!3m1!1s0x89c258f07d5da561:0x61f6aa300ba8339d"
+ }
+ ],
+ )
+ )
+ if not items:
+ _check("hotel returned", False)
+ return
+ it = items[0]
+ _check(
+ "hotelStars + check-in/out dates",
+ it.get("hotelStars") == "5 stars"
+ and (it.get("checkInDate") or "") < (it.get("checkOutDate") or ""),
+ f"stars={it.get('hotelStars')}, {it.get('checkInDate')}..{it.get('checkOutDate')}",
+ )
+ similar = it.get("similarHotelsNearby") or []
+ _check(
+ "similarHotelsNearby with fid/score",
+ len(similar) >= 3 and all(h.get("title") and h.get("fid") for h in similar),
+ f"{len(similar)} hotels, first={similar[0].get('title') if similar else None}",
+ )
+ ads = it.get("hotelAds") or []
+ _check(
+ "hotelAds with booking links",
+ bool(ads) and all(a.get("url", "").startswith("https://") for a in ads),
+ f"{len(ads)} ads",
+ )
+
+
+async def step_all_places_scan() -> None:
+ _hr("P — allPlacesNoSearchAction area scan (Times Square 400m)")
+ items = await scrape_places(
+ GoogleMapsScrapeInput(
+ allPlacesNoSearchAction="all_places_no_search_mouse",
+ customGeolocation={
+ "type": "Point",
+ "coordinates": [-73.9855, 40.758],
+ "radiusKm": 0.4,
+ },
+ maxCrawledPlacesPerSearch=25,
+ )
+ )
+ fids = [i.get("fid") for i in items]
+ _check(
+ "25 unique places without any search term",
+ len(items) == 25 and len(set(fids)) == 25,
+ f"{len(items)} items",
+ )
+ cats = {i.get("categoryName") for i in items if i.get("categoryName")}
+ _check(
+ "multiple categories represented (sweep, not one query)",
+ len(cats) >= 5,
+ f"{len(cats)} categories",
+ )
+ in_view = sum(
+ 1 for i in items if i.get("location") and 40.74 < i["location"]["lat"] < 40.78
+ )
+ _check("scan respects the viewport", in_view >= 23, f"{in_view}/25 in bounds")
+
+
+async def step_short_url() -> None:
+ _hr("Q — short link (maps.app.goo.gl Firebase redirect -> place)")
+ # A real shared short link -> "Aux Merveilleux de Fred" (NYC). These are
+ # Firebase Dynamic Links (JS interstitial), so this exercises the browser-
+ # render redirect path in resolve_fid. fid is the ground-truth invariant.
+ it = await scrape_one("https://maps.app.goo.gl/8YUvDPbQPrasqC528")
+ _check(
+ "maps.app.goo.gl short link resolves to the right place",
+ it is not None
+ and it.get("fid") == "0x89c259957da502cd:0xed3eb58a4ca08a95"
+ and "merveilleux" in (it.get("title") or "").lower(),
+ f"title={it.get('title') if it else None!r}, fid={it.get('fid') if it else None}",
+ )
+
+
+async def step_filter_variants() -> None:
+ _hr("R — search filter variants (only_exact, categoryFilterWords)")
+ # only_exact: the parsed title must equal the query exactly (Seattle
+ # Starbucks are titled "Starbucks Coffee Company", not "Starbucks").
+ exact = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["Starbucks Coffee Company"],
+ locationQuery="Seattle, WA",
+ maxCrawledPlacesPerSearch=8,
+ searchMatching="only_exact",
+ )
+ )
+ titles = [i.get("title") for i in exact]
+ _check(
+ "searchMatching=only_exact keeps only exact-title matches",
+ bool(titles)
+ and all((t or "").lower() == "starbucks coffee company" for t in titles),
+ f"{len(titles)} items, titles={titles[:3]}",
+ )
+ # categoryFilterWords: drop places whose categories don't include a word.
+ coffee = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["food"],
+ locationQuery="Seattle, WA",
+ maxCrawledPlacesPerSearch=6,
+ categoryFilterWords=["coffee"],
+ )
+ )
+ _check(
+ "categoryFilterWords keeps only matching categories",
+ bool(coffee)
+ and all(
+ any("coffee" in c.lower() for c in (i.get("categories") or []))
+ for i in coffee
+ ),
+ f"{len(coffee)} items, cats={[i.get('categories') for i in coffee][:2]}",
+ )
+
+
+async def step_reviews_origin() -> None:
+ _hr("S — reviewsOrigin=google filter")
+ # The public BOQ feed only carries Google-origin reviews (partner reviews
+ # aren't exposed anonymously), so the invariant is: nothing non-Google
+ # leaks through when origin is pinned to google.
+ items = await scrape_reviews(
+ GoogleMapsReviewsInput(
+ placeIds=[_KIMS_PLACE_ID], maxReviews=10, reviewsOrigin="google"
+ )
+ )
+ origins = {(r.get("reviewOrigin") or "Google") for r in items}
+ _check(
+ "reviewsOrigin=google -> only Google-origin reviews",
+ bool(items) and origins <= {"Google"},
+ f"{len(items)} reviews, origins={origins}",
+ )
+
+
+async def step_inline_consistency() -> None:
+ _hr("H — inline reviews[] match the standalone reviews endpoint")
+ place = await scrape_one(
+ f"https://www.google.com/maps/place/?q=place_id:{_KIMS_PLACE_ID}",
+ maxReviews=5,
+ )
+ standalone = await scrape_reviews(
+ GoogleMapsReviewsInput(placeIds=[_KIMS_PLACE_ID], maxReviews=5)
+ )
+ if not place or not standalone:
+ _check("both sources returned data", False)
+ return
+ inline_ids = {r.get("reviewId") for r in (place.get("reviews") or [])}
+ standalone_ids = {r.get("reviewId") for r in standalone}
+ overlap = len(inline_ids & standalone_ids)
+ _check(
+ "inline and standalone reviews overlap (same feed)",
+ overlap >= 3, # feed ordering can shift slightly between calls
+ f"{overlap}/5 shared review IDs",
+ )
+
+
+async def main() -> int:
+ steps = [
+ step_diverse_places(),
+ step_cid_url(),
+ step_review_sorts(),
+ step_start_date_cutoff(),
+ step_personal_data(),
+ step_localization(),
+ step_big_place_pagination(),
+ step_inline_consistency(),
+ step_search_discovery(),
+ step_search_filters(),
+ step_search_closed(),
+ step_search_url_and_geo(),
+ step_search_pagination_stress(),
+ step_detail_extras(),
+ step_hotel_fields(),
+ step_all_places_scan(),
+ step_short_url(),
+ step_filter_variants(),
+ step_reviews_origin(),
+ ]
+ for coro in steps:
+ try:
+ await coro
+ except Exception as e: # keep going; report the step as failed
+ _check(f"step crashed: {coro}", False, repr(e))
+
+ _hr("SUMMARY")
+ passed = sum(1 for _, ok, _ in _CHECKS if ok)
+ for label, ok, detail in _CHECKS:
+ if not ok:
+ print(f" FAILED: {label} — {detail}")
+ print(f" {passed}/{len(_CHECKS)} checks passed")
+ return 0 if passed == len(_CHECKS) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))
diff --git a/surfsense_backend/scripts/e2e_google_maps_scraper.py b/surfsense_backend/scripts/e2e_google_maps_scraper.py
new file mode 100644
index 000000000..be452f3f1
--- /dev/null
+++ b/surfsense_backend/scripts/e2e_google_maps_scraper.py
@@ -0,0 +1,241 @@
+"""Manual functional e2e for the Google Maps scraper (app/proprietary/platforms/google_maps).
+
+Run from the backend directory:
+ cd surfsense_backend
+ uv run python scripts/e2e_google_maps_scraper.py
+ # or: .\\.venv\\Scripts\\python.exe scripts/e2e_google_maps_scraper.py
+
+NOT a pytest test (needs live network + optional proxy creds). It:
+ Step 1 — scrapes a known place URL and prints the core fields.
+ Step 2 — scrapes the same place by bare placeId (HTML -> fid -> RPC path).
+ Step 3 — dumps the raw place darray (jd[6] of /maps/preview/place) to
+ tests/unit/platforms/google_maps/fixtures/ for the offline parser test.
+ Step 4 — scrapes reviews via the Reviews endpoint (BOQ feed), checks fields.
+ Step 5 — paginates past one page (maxReviews=15) and checks the count.
+ Step 6 — place scrape with maxReviews>0 attaches inline reviews[].
+ Step 7 — dumps a raw BOQ reviews page fixture for the offline parser test.
+ Step 8 — search discovery (searchStringsArray + locationQuery), checks items.
+ Step 9 — dumps a raw map-search response fixture for the offline parser test.
+"""
+
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+# Windows consoles default to cp1252; reviews/ratings contain non-latin chars.
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_BACKEND_ROOT))
+for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
+ if _candidate.exists():
+ load_dotenv(_candidate)
+ break
+
+from app.proprietary.platforms.google_maps import ( # noqa: E402
+ GoogleMapsReviewsInput,
+ GoogleMapsScrapeInput,
+ scrape_places,
+ scrape_reviews,
+)
+from app.proprietary.platforms.google_maps.fetch import ( # noqa: E402
+ build_search_url,
+ fetch_place_darray,
+ fetch_rpc_json,
+ iter_reviews_pages,
+)
+from app.proprietary.platforms.google_maps.url_resolver import extract_fid # noqa: E402
+
+# A well-known, stable place (the restaurant used in the Apify output example).
+_PLACE_URL = (
+ "https://www.google.com/maps/place/Kim's+Island/"
+ "@40.5107736,-74.2482624,17z/data=!4m6!3m5!1s0x89c3ca9c11f90c25:"
+ "0x6cc8dba851799f09!8m2!3d40.5107736!4d-74.2482624!16s%2Fg%2F1tmgdcj8?hl=en"
+)
+
+_FIXTURE_DIR = (
+ _BACKEND_ROOT / "tests" / "unit" / "platforms" / "google_maps" / "fixtures"
+)
+
+
+def _hr(title: str) -> None:
+ print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
+
+
+def _check(label: str, ok: bool, detail: str = "") -> bool:
+ print(f" [{'PASS' if ok else 'FAIL'}] {label}{f' — {detail}' if detail else ''}")
+ return ok
+
+
+async def step1_place() -> bool:
+ _hr("STEP 1 — scrape a known place URL")
+ items = await scrape_places(GoogleMapsScrapeInput(startUrls=[{"url": _PLACE_URL}]))
+ if not items:
+ return _check("place scraped", False, "no items returned")
+ it = items[0]
+ print(json.dumps(it, indent=2, ensure_ascii=False)[:2500])
+ ok = bool(it.get("title")) and it.get("placeId") is not None
+ return _check(
+ "place has title + placeId",
+ ok,
+ f"{it.get('title')!r} / {it.get('totalScore')}★ / {it.get('reviewsCount')} reviews",
+ )
+
+
+async def step2_place_id() -> bool:
+ _hr("STEP 2 — scrape by bare placeId (HTML -> fid -> RPC path)")
+ items = await scrape_places(
+ GoogleMapsScrapeInput(placeIds=["ChIJJQz5EZzKw4kRCZ95UajbyGw"])
+ )
+ if not items:
+ return _check("placeId scraped", False, "no items returned")
+ it = items[0]
+ return _check(
+ "placeId resolves to same place",
+ it.get("title") == "Kim's Island",
+ f"{it.get('title')!r}",
+ )
+
+
+async def step3_dump_fixture() -> bool:
+ _hr("STEP 3 — dump raw place darray fixture for offline test")
+ fid = extract_fid(_PLACE_URL)
+ if not fid:
+ return _check("extracted fid from URL", False)
+ darray = await fetch_place_darray(fid)
+ if not darray:
+ return _check("fetched place darray via RPC", False)
+ _FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
+ (_FIXTURE_DIR / "place_darray.json").write_text(
+ json.dumps(darray), encoding="utf-8"
+ )
+ return _check("dumped fixture", True, f"-> {_FIXTURE_DIR / 'place_darray.json'}")
+
+
+async def step4_reviews() -> bool:
+ _hr("STEP 4 — reviews endpoint (one page, newest first)")
+ items = await scrape_reviews(
+ GoogleMapsReviewsInput(startUrls=[{"url": _PLACE_URL}], maxReviews=5)
+ )
+ if not items:
+ return _check("reviews returned", False, "no items")
+ it = items[0]
+ print(json.dumps(it, indent=2, ensure_ascii=False)[:1800])
+ ok = (
+ bool(it.get("name"))
+ and it.get("stars") is not None
+ and bool(it.get("reviewId"))
+ and it.get("title") == "Kim's Island" # place header stamped on
+ and bool(it.get("publishedAtDate"))
+ )
+ return _check(
+ "review has author/stars/id/place header",
+ ok and len(items) == 5,
+ f"{len(items)} reviews, first by {it.get('name')!r} ({it.get('stars')}★)",
+ )
+
+
+async def step5_pagination() -> bool:
+ _hr("STEP 5 — pagination past one page (maxReviews=15)")
+ items = await scrape_reviews(
+ GoogleMapsReviewsInput(placeIds=["ChIJJQz5EZzKw4kRCZ95UajbyGw"], maxReviews=15)
+ )
+ dates = [i.get("publishedAtDate") for i in items[:3]]
+ return _check(
+ "got 15 reviews across >1 page",
+ len(items) == 15,
+ f"{len(items)} reviews; newest: {dates}",
+ )
+
+
+async def step6_inline_reviews() -> bool:
+ _hr("STEP 6 — place scrape with maxReviews=3 attaches inline reviews[]")
+ items = await scrape_places(
+ GoogleMapsScrapeInput(startUrls=[{"url": _PLACE_URL}], maxReviews=3)
+ )
+ if not items:
+ return _check("place scraped", False, "no items")
+ reviews = items[0].get("reviews") or []
+ return _check(
+ "place item carries 3 inline reviews",
+ len(reviews) == 3 and bool(reviews[0].get("name")),
+ f"{len(reviews)} reviews inline",
+ )
+
+
+async def step7_dump_reviews_fixture() -> bool:
+ _hr("STEP 7 — dump raw BOQ reviews page fixture for offline test")
+ fid = extract_fid(_PLACE_URL)
+ async for raw_page in iter_reviews_pages(fid, sort="newest", max_pages=1):
+ _FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
+ (_FIXTURE_DIR / "boq_reviews_page.json").write_text(
+ json.dumps(raw_page), encoding="utf-8"
+ )
+ return _check(
+ "dumped fixture",
+ len(raw_page) > 0,
+ f"{len(raw_page)} reviews -> {_FIXTURE_DIR / 'boq_reviews_page.json'}",
+ )
+ return _check("fetched a reviews page", False)
+
+
+async def step8_search() -> bool:
+ _hr("STEP 8 — search discovery (query + locationQuery)")
+ items = await scrape_places(
+ GoogleMapsScrapeInput(
+ searchStringsArray=["coffee shop"],
+ locationQuery="Seattle, WA",
+ maxCrawledPlacesPerSearch=5,
+ )
+ )
+ if not items:
+ return _check("search returned items", False, "no items")
+ it = items[0]
+ print(json.dumps(it, indent=2, ensure_ascii=False)[:1500])
+ ok = (
+ len(items) == 5
+ and all(i.get("title") and i.get("placeId") and i.get("fid") for i in items)
+ and [i.get("rank") for i in items] == [1, 2, 3, 4, 5]
+ and it.get("searchString") == "coffee shop"
+ )
+ return _check(
+ "5 ranked places with title/placeId/fid",
+ ok,
+ f"first: {it.get('title')!r} ({it.get('totalScore')}★, {it.get('city')})",
+ )
+
+
+async def step9_dump_search_fixture() -> bool:
+ _hr("STEP 9 — dump raw map-search response fixture for offline test")
+ url = build_search_url("pizza new york")
+ jd = await fetch_rpc_json(url)
+ if not isinstance(jd, list):
+ return _check("fetched search response", False)
+ _FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
+ (_FIXTURE_DIR / "search_response.json").write_text(json.dumps(jd), encoding="utf-8")
+ return _check("dumped fixture", True, f"-> {_FIXTURE_DIR / 'search_response.json'}")
+
+
+async def main() -> int:
+ results = [
+ await step1_place(),
+ await step2_place_id(),
+ await step3_dump_fixture(),
+ await step4_reviews(),
+ await step5_pagination(),
+ await step6_inline_reviews(),
+ await step7_dump_reviews_fixture(),
+ await step8_search(),
+ await step9_dump_search_fixture(),
+ ]
+ _hr("SUMMARY")
+ print(f" {sum(results)}/{len(results)} steps passed")
+ return 0 if all(results) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))
diff --git a/surfsense_backend/scripts/e2e_google_search.py b/surfsense_backend/scripts/e2e_google_search.py
new file mode 100644
index 000000000..9c3c02fcf
--- /dev/null
+++ b/surfsense_backend/scripts/e2e_google_search.py
@@ -0,0 +1,206 @@
+"""Live end-to-end checks for the Google Search scraper (needs proxy + browser).
+
+ .venv/Scripts/python.exe scripts/e2e_google_search.py
+
+Covers: a plain query, a site: filter, text ads, product ads, the
+focusOnPaidAds retry (commercial = ads found; non-commercial = retries capped,
+organic still returned), People-Also-Ask answer expansion, sitelinks, the AI
+Overview, the mobile layout, filter=0, base64 icons, and Google AI Mode.
+
+Pass case names as args to run a subset, e.g.:
+
+ .venv/Scripts/python.exe scripts/e2e_google_search.py paa
+"""
+
+import asyncio
+import logging
+import sys
+import time
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+
+_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_ROOT))
+load_dotenv(_ROOT / ".env")
+
+logging.basicConfig(level=logging.WARNING)
+logging.getLogger("app.proprietary.platforms.google_search.scraper").setLevel(
+ logging.INFO
+)
+
+from app.proprietary.platforms.google_search import ( # noqa: E402
+ GoogleSearchScrapeInput,
+ scrape_serps,
+)
+from app.proprietary.platforms.google_search.fetch import close_sessions # noqa: E402
+
+
+async def run_ai_mode(label: str, *, queries: str) -> None:
+ print(f"\n=== {label} ===")
+ t0 = time.perf_counter()
+ inp = GoogleSearchScrapeInput(
+ queries=queries,
+ countryCode="us",
+ languageCode="en",
+ aiModeSearch={"enableAiMode": True},
+ )
+ items = await scrape_serps(inp, limit=2)
+ ai_items = [i for i in items if i["aiModeResult"]]
+ assert ai_items, f"{label}: no aiModeResult item emitted"
+ res = ai_items[0]["aiModeResult"]
+ print(
+ f" text={len(res['text'])} chars, sources={len(res['sources'])} "
+ f"({time.perf_counter() - t0:.0f}s)"
+ )
+ print(f" {res['text'][:130]!r}")
+ for s in res["sources"][:3]:
+ print(f" src: {(s['title'] or '')[:60]!r}")
+ assert res["text"] and len(res["text"]) > 100, f"{label}: answer too short"
+ assert res["sources"], f"{label}: no cited sources"
+ assert "udm=50" in ai_items[0]["searchQuery"]["url"]
+
+
+async def run(
+ label: str,
+ *,
+ expect_ads=False,
+ expect_products=False,
+ expect_paa_answers=False,
+ expect_sitelinks=False,
+ expect_aio=False,
+ expect_device=None,
+ expect_icons=False,
+ **kwargs,
+) -> None:
+ print(f"\n=== {label} ===")
+ t0 = time.perf_counter()
+ inp = GoogleSearchScrapeInput(countryCode="us", languageCode="en", **kwargs)
+ items = await scrape_serps(inp, limit=1)
+ assert items, f"{label}: no SERP item"
+ it = items[0]
+ paa_answered = [p for p in it["peopleAlsoAsk"] if p["answer"]]
+ sitelinked = [o for o in it["organicResults"] if o["siteLinks"]]
+ print(f" term={it['searchQuery']['term']!r} resultsTotal={it['resultsTotal']}")
+ print(
+ f" organic={len(it['organicResults'])} paidResults={len(it['paidResults'])} "
+ f"paidProducts={len(it['paidProducts'])} related={len(it['relatedQueries'])} "
+ f"suggested={len(it['suggestedResults'])} "
+ f"paa={len(it['peopleAlsoAsk'])} (answered={len(paa_answered)}) "
+ f"({time.perf_counter() - t0:.0f}s)"
+ )
+ for o in sitelinked[:2]:
+ print(
+ f" [sitelinks on #{o['position']}] "
+ + ", ".join(s["title"] for s in o["siteLinks"][:5])
+ )
+ aio = it["aiOverview"]
+ if aio:
+ print(
+ f" [aiOverview] content={len(aio['content'])} chars, "
+ f"sources={len(aio['sources'])}"
+ )
+ print(f" {aio['content'][:110]!r}")
+ for s in aio["sources"][:3]:
+ print(f" src: {(s['title'] or '')[:55]!r}")
+ for a in it["paidResults"][:3]:
+ print(f" [ad {a['adPosition']}] {a['title'][:44]!r} {(a['url'] or '')[:45]}")
+ for p in it["paidProducts"][:3]:
+ print(f" [pla] {p['title'][:40]!r} {p['prices']} {p['displayedUrl']}")
+ for p in paa_answered[:3]:
+ print(f" [paa] {p['question'][:48]!r}")
+ print(f" A: {p['answer'][:90]!r}")
+ print(f" src: {p['url'] or '-'} | {(p['title'] or '-')[:45]}")
+ assert it["organicResults"], f"{label}: no organic results"
+ if expect_ads:
+ assert it["paidResults"], f"{label}: expected text ads, got none"
+ if expect_products:
+ assert it["paidProducts"], f"{label}: expected product ads, got none"
+ if expect_paa_answers:
+ assert paa_answered, f"{label}: expected PAA answers, got none"
+ if expect_sitelinks:
+ assert sitelinked, f"{label}: expected sitelinks, got none"
+ assert it["suggestedResults"], f"{label}: expected suggestedResults"
+ if expect_aio:
+ assert aio and aio["content"], f"{label}: expected an AI Overview"
+ assert aio["sources"], f"{label}: expected AI Overview sources"
+ if expect_device:
+ assert it["searchQuery"]["device"] == expect_device, (
+ f"{label}: device={it['searchQuery']['device']}"
+ )
+ if expect_icons:
+ iconed = [
+ o
+ for o in it["organicResults"]
+ if (o["icon"] or "").startswith("data:image")
+ ]
+ print(
+ f" [icons] {len(iconed)}/{len(it['organicResults'])} organic "
+ f"carry a base64 favicon"
+ )
+ assert iconed, f"{label}: expected base64 icons on organic results"
+
+
+_CASES = {
+ "plain": lambda: run("plain query", queries="python asyncio tutorial"),
+ "site": lambda: run("site: filter", queries="machine learning", site="arxiv.org"),
+ "ads": lambda: run("text ads", queries="car insurance quotes", expect_ads=True),
+ "products": lambda: run(
+ "product ads", queries="buy running shoes", expect_products=True
+ ),
+ "focus": lambda: run(
+ "focusOnPaidAds (commercial)",
+ queries="car insurance quotes",
+ focusOnPaidAds=True,
+ expect_ads=True,
+ ),
+ "focus-neg": lambda: run(
+ "focusOnPaidAds (non-commercial, retries capped)",
+ queries="python asyncio tutorial",
+ focusOnPaidAds=True,
+ ),
+ "paa": lambda: run(
+ "people also ask", queries="what is seo", expect_paa_answers=True
+ ),
+ "sitelinks": lambda: run(
+ "sitelinks + suggested (brand query)", queries="amazon", expect_sitelinks=True
+ ),
+ "aio": lambda: run("AI Overview", queries="benefits of green tea", expect_aio=True),
+ "mobile": lambda: run(
+ "mobile layout (mobileResults)",
+ queries="best seo tools",
+ mobileResults=True,
+ expect_device="MOBILE",
+ ),
+ "unfiltered": lambda: run(
+ "includeUnfilteredResults (filter=0)",
+ queries="python asyncio tutorial",
+ includeUnfilteredResults=True,
+ ),
+ "icons": lambda: run(
+ "includeIcons (base64 favicons)",
+ queries="github",
+ includeIcons=True,
+ expect_icons=True,
+ ),
+ "aimode": lambda: run_ai_mode(
+ "Google AI Mode (udm=50)", queries="what is quantum computing"
+ ),
+}
+
+
+async def main() -> None:
+ names = sys.argv[1:] or list(_CASES)
+ try:
+ for name in names:
+ await _CASES[name]()
+ finally:
+ await close_sessions()
+ print("\nALL E2E OK")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/surfsense_backend/scripts/e2e_phase3_crawl_billing.py b/surfsense_backend/scripts/e2e_phase3_crawl_billing.py
new file mode 100644
index 000000000..c60cd0927
--- /dev/null
+++ b/surfsense_backend/scripts/e2e_phase3_crawl_billing.py
@@ -0,0 +1,140 @@
+"""Manual functional e2e for Phase 3 crawler core (3a / 3b).
+
+Run from the backend directory:
+ cd surfsense_backend
+ uv run python scripts/e2e_phase3_crawl_billing.py
+ # or: .\\.venv\\Scripts\\python.exe scripts/e2e_phase3_crawl_billing.py
+
+What it exercises (everything REAL — live network, live proxy, live DB reads):
+
+ Stage 1 (3a + 3b) — direct fetch + proxy egress-IP proof + crawl_url ladder.
+
+Crawl billing now lives entirely in the ``web.crawl`` capability (charged
+directly on the wallet via ``charge_capability``); there is no longer a
+chat-turn "fold" surface to exercise here.
+
+This is NOT a pytest test (it needs a live stack + proxy creds + network). It
+is the manual functional counterpart to the unit suites; the undetectability /
+anti-bot scorecard is a separate deliverable (03f), after 03d/03e.
+"""
+
+import asyncio
+import sys
+from pathlib import Path
+from urllib.parse import urlsplit
+
+from dotenv import load_dotenv
+
+# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_BACKEND_ROOT))
+for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
+ if _candidate.exists():
+ load_dotenv(_candidate)
+ break
+
+
+# Content-rich, generally crawl-friendly targets (real extraction expected).
+_ARTICLE_URLS = [
+ "https://en.wikipedia.org/wiki/Competitive_intelligence",
+ "https://en.wikipedia.org/wiki/Market_research",
+]
+_IP_ECHO = "https://api.ipify.org?format=json"
+
+
+def _mask(url: str | None) -> str:
+ if not url:
+ return ""
+ p = urlsplit(url)
+ host = p.hostname or "?"
+ port = f":{p.port}" if p.port else ""
+ creds = "***@" if p.username else ""
+ return f"{p.scheme}://{creds}{host}{port}"
+
+
+def _hr(title: str) -> None:
+ print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
+
+
+def _check(label: str, ok: bool, detail: str = "") -> bool:
+ print(f" [{'PASS' if ok else 'FAIL'}] {label}{f' — {detail}' if detail else ''}")
+ return ok
+
+
+# ===========================================================================
+# Stage 1 — crawl core (3a) + proxy routing (3b)
+# ===========================================================================
+async def stage1_crawl_and_proxy() -> bool:
+ _hr("STAGE 1 — crawl_url ladder (3a) + proxy egress (3b)")
+ from scrapling.fetchers import AsyncFetcher
+
+ from app.proprietary.web_crawler import CrawlOutcomeStatus, WebCrawlerConnector
+ from app.utils.proxy import get_active_provider, get_proxy_url, is_pool_backed
+
+ ok = True
+ provider = get_active_provider()
+ proxy_url = get_proxy_url()
+ print(f" active proxy provider : {provider.name}")
+ print(f" proxy url : {_mask(proxy_url)}")
+ print(f" pool-backed (rotates) : {is_pool_backed()}")
+
+ # Proxy egress-IP proof: direct IP vs proxied IP should differ.
+ direct_ip = proxied_ip = None
+ try:
+ direct = await AsyncFetcher.get(_IP_ECHO, impersonate="chrome", timeout=30)
+ direct_ip = direct.json().get("ip")
+ except Exception as exc:
+ print(f" [INFO] direct IP fetch failed: {exc}")
+ if proxy_url:
+ try:
+ via = await AsyncFetcher.get(
+ _IP_ECHO, impersonate="chrome", proxy=proxy_url, timeout=45
+ )
+ proxied_ip = via.json().get("ip")
+ except Exception as exc:
+ print(f" [INFO] proxied IP fetch failed: {exc}")
+ print(f" egress IP (direct) : {direct_ip}")
+ print(f" egress IP (via proxy) : {proxied_ip}")
+ if proxy_url:
+ ok &= _check(
+ "proxy changes egress IP",
+ bool(proxied_ip) and proxied_ip != direct_ip,
+ f"{direct_ip} -> {proxied_ip}",
+ )
+ else:
+ print(" [INFO] no proxy configured — skipping egress-IP assertion")
+
+ # crawl_url end-to-end on a content-rich page.
+ crawler = WebCrawlerConnector()
+ outcome = await crawler.crawl_url(_ARTICLE_URLS[0])
+ content = (outcome.result or {}).get("content", "") if outcome.result else ""
+ tier = (outcome.result or {}).get("crawler_type", "?") if outcome.result else "?"
+ ok &= _check(
+ "crawl_url returns SUCCESS with content",
+ outcome.status is CrawlOutcomeStatus.SUCCESS and len(content) > 200,
+ f"status={outcome.status.value} tier={tier} chars={len(content)}",
+ )
+ return ok
+
+
+async def main() -> int:
+ print("Phase 3 functional e2e (3a/3b) — live network + proxy, DB rolled back")
+ results: dict[str, bool] = {}
+ for name, coro in (("Stage 1 crawl+proxy", stage1_crawl_and_proxy),):
+ try:
+ results[name] = await coro()
+ except Exception as exc:
+ import traceback
+
+ traceback.print_exc()
+ print(f" [ERROR] {name} raised: {exc}")
+ results[name] = False
+
+ _hr("SUMMARY")
+ for name, ok in results.items():
+ print(f" {'PASS' if ok else 'FAIL/SKIP'} — {name}")
+ return 0 if all(results.values()) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))
diff --git a/surfsense_backend/scripts/e2e_reddit_scraper.py b/surfsense_backend/scripts/e2e_reddit_scraper.py
new file mode 100644
index 000000000..7bedc0812
--- /dev/null
+++ b/surfsense_backend/scripts/e2e_reddit_scraper.py
@@ -0,0 +1,211 @@
+"""Manual functional e2e for the Reddit scraper (app/proprietary/platforms/reddit).
+
+Run from the backend directory:
+ cd surfsense_backend
+ uv run python scripts/e2e_reddit_scraper.py
+ # or: .venv/bin/python scripts/e2e_reddit_scraper.py
+
+This is NOT a pytest test (it needs live network + a residential/custom proxy).
+It:
+
+ Step 0 — go/no-go probe (folds in the old scripts/reddit_probe.py): open a
+ proxy session, warm a ``loid`` (svc/shreddit first, old.reddit fallback),
+ then do sequential ``.json`` fetches on the SAME sticky IP and assert each
+ returns a Reddit Listing. If this fails the whole approach is invalid —
+ later steps are skipped.
+ Step 1 — scrape a discovered post URL (post + a few comments).
+ Step 2 — scrape a subreddit listing.
+ Step 3 — run a search query.
+ Step 4 — scrape a user profile.
+ Step 5 — dump trimmed raw ``.json`` fixtures into
+ tests/unit/platforms/reddit/fixtures/ for the offline parser tests.
+"""
+
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_BACKEND_ROOT))
+for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
+ if _candidate.exists():
+ load_dotenv(_candidate)
+ break
+
+from app.proprietary.platforms.reddit import ( # noqa: E402
+ RedditScrapeInput,
+ scrape_reddit,
+)
+from app.proprietary.platforms.reddit.fetch import ( # noqa: E402
+ fetch_json,
+ proxy_session,
+ warm_session,
+)
+from app.proprietary.platforms.reddit.parsers import children # noqa: E402
+
+_SUBREDDIT = "python"
+_SEARCH_TERM = "async web scraping"
+_USER = "spez"
+
+_FIXTURE_DIR = _BACKEND_ROOT / "tests" / "unit" / "platforms" / "reddit" / "fixtures"
+
+
+def _hr(title: str) -> None:
+ print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
+
+
+def _check(label: str, ok: bool, detail: str = "") -> bool:
+ print(f" [{'PASS' if ok else 'FAIL'}] {label}{f' — {detail}' if detail else ''}")
+ return ok
+
+
+def _listing_count(data) -> int | None:
+ """Child count if ``data`` looks like a Reddit Listing (or post+comments)."""
+ try:
+ if isinstance(data, dict) and data.get("kind") == "Listing":
+ return len(data["data"]["children"])
+ if isinstance(data, list):
+ return sum(
+ len(x["data"]["children"])
+ for x in data
+ if isinstance(x, dict) and x.get("kind") == "Listing"
+ )
+ except Exception:
+ return None
+ return None
+
+
+async def _first_post_permalink() -> str | None:
+ """Discover a live post URL from the subreddit's hot listing."""
+ listing = await fetch_json(f"r/{_SUBREDDIT}/hot", {"limit": 5})
+ kids = children(listing)
+ if not kids:
+ return None
+ permalink = (kids[0].get("data") or {}).get("permalink")
+ return f"https://www.reddit.com{permalink}" if permalink else None
+
+
+async def step0_probe() -> bool:
+ _hr("STEP 0 — go/no-go: loid warm-up + sticky .json")
+ async with proxy_session() as holder:
+ if holder.session is None:
+ return _check(
+ "proxy configured", False, "no proxy -> set PROXY_PROVIDER + creds"
+ )
+ minted = await warm_session(holder.session)
+ holder.warmed = True # don't let fetch_json re-warm; we just warmed it
+ _check("loid warm-up minted a session", minted)
+ oks: list[bool] = []
+ for path in (f"r/{_SUBREDDIT}/hot", "r/programming/new", f"r/{_SUBREDDIT}/hot"):
+ data = await fetch_json(path, {"limit": 5})
+ n = _listing_count(data)
+ print(f" {path} -> listing_count={n}")
+ oks.append(n is not None and n > 0)
+ await asyncio.sleep(1.0)
+ return _check("sequential .json on sticky IP", minted and all(oks))
+
+
+async def step1_post() -> bool:
+ _hr("STEP 1 — scrape a discovered post (post + comments)")
+ url = await _first_post_permalink()
+ if not url:
+ return _check("discovered a post URL", False)
+ items = await scrape_reddit(
+ RedditScrapeInput(startUrls=[{"url": url}], maxComments=5)
+ )
+ posts = [i for i in items if i.get("dataType") == "post"]
+ comments = [i for i in items if i.get("dataType") == "comment"]
+ print(f" posts={len(posts)} comments={len(comments)} url={url}")
+ return _check("post scraped", bool(posts) and bool(posts[0].get("id")))
+
+
+async def step2_subreddit() -> bool:
+ _hr("STEP 2 — scrape a subreddit listing")
+ items = await scrape_reddit(
+ RedditScrapeInput(
+ startUrls=[{"url": f"https://www.reddit.com/r/{_SUBREDDIT}/hot"}],
+ maxPostCount=5,
+ skipComments=True,
+ )
+ )
+ posts = [i for i in items if i.get("dataType") == "post"]
+ for it in posts[:5]:
+ print(f" - {it.get('id')} | {it.get('title')}")
+ return _check("subreddit returned posts", len(posts) > 0, f"{len(posts)} posts")
+
+
+async def step3_search() -> bool:
+ _hr("STEP 3 — search query")
+ items = await scrape_reddit(
+ RedditScrapeInput(searches=[_SEARCH_TERM], sort="relevance", maxItems=5)
+ )
+ for it in items[:5]:
+ print(f" - {it.get('id')} | {it.get('title')}")
+ return _check("search returned results", len(items) > 0, f"{len(items)} items")
+
+
+async def step4_user() -> bool:
+ _hr("STEP 4 — user profile")
+ items = await scrape_reddit(
+ RedditScrapeInput(
+ startUrls=[{"url": f"https://www.reddit.com/user/{_USER}"}], maxItems=5
+ )
+ )
+ print(f" {len(items)} items for u/{_USER}")
+ return _check("user returned items", len(items) > 0, f"{len(items)} items")
+
+
+async def step5_dump_fixtures() -> bool:
+ _hr("STEP 5 — dump trimmed .json fixtures for offline tests")
+ listing = await fetch_json(f"r/{_SUBREDDIT}/hot", {"limit": 25})
+ url = await _first_post_permalink()
+ post = None
+ if url:
+ path = url.split("reddit.com/")[-1].strip("/")
+ post = await fetch_json(path, {"limit": 20})
+
+ _FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
+ wrote = []
+ if _listing_count(listing):
+ (_FIXTURE_DIR / "sample_listing.json").write_text(
+ json.dumps(listing), encoding="utf-8"
+ )
+ wrote.append("sample_listing.json")
+ if isinstance(post, list) and post:
+ (_FIXTURE_DIR / "sample_post.json").write_text(
+ json.dumps(post), encoding="utf-8"
+ )
+ wrote.append("sample_post.json")
+ # A single comment thing, for the comment-mapping fixture.
+ comment_kids = children(post[1]) if len(post) > 1 else []
+ first_comment = next((c for c in comment_kids if c.get("kind") == "t1"), None)
+ if first_comment:
+ (_FIXTURE_DIR / "sample_comment.json").write_text(
+ json.dumps(first_comment), encoding="utf-8"
+ )
+ wrote.append("sample_comment.json")
+ return _check("dumped fixtures", bool(wrote), f"{wrote} -> {_FIXTURE_DIR}")
+
+
+async def main() -> int:
+ results = [await step0_probe()]
+ if not results[-1]:
+ print("\nloid probe failed — the approach is invalid on this IP/proxy.")
+ print("Aborting remaining steps.")
+ return 1
+ results.append(await step1_post())
+ results.append(await step2_subreddit())
+ results.append(await step3_search())
+ results.append(await step4_user())
+ results.append(await step5_dump_fixtures())
+ _hr("SUMMARY")
+ print(f" {sum(results)}/{len(results)} steps passed")
+ return 0 if all(results) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))
diff --git a/surfsense_backend/scripts/e2e_youtube_scraper.py b/surfsense_backend/scripts/e2e_youtube_scraper.py
new file mode 100644
index 000000000..ce409e051
--- /dev/null
+++ b/surfsense_backend/scripts/e2e_youtube_scraper.py
@@ -0,0 +1,205 @@
+"""Manual functional e2e for the YouTube scraper (app/proprietary/platforms/youtube).
+
+Run from the backend directory:
+ cd surfsense_backend
+ uv run python scripts/e2e_youtube_scraper.py
+ # or: .\\.venv\\Scripts\\python.exe scripts/e2e_youtube_scraper.py
+
+This is NOT a pytest test (it needs live network + optional proxy creds). It:
+
+ Step 0 — validates the keyless InnerTube ``search`` POST works; if it 400s the
+ scraper transparently retries with the public web key (proven here).
+ Step 1 — scrapes a known video URL (metadata + optional subtitles).
+ Step 2 — runs a search query and prints the first few results.
+ Step 3 — scrapes a small channel's latest videos.
+ Step 4 — dumps trimmed raw ytInitialData / ytInitialPlayerResponse fixtures to
+ tests/unit/platforms/youtube/fixtures/ for the offline parser test.
+"""
+
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_BACKEND_ROOT))
+for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
+ if _candidate.exists():
+ load_dotenv(_candidate)
+ break
+
+from app.proprietary.platforms.youtube import ( # noqa: E402
+ YouTubeCommentsInput,
+ YouTubeScrapeInput,
+ scrape_comments,
+ scrape_youtube,
+)
+from app.proprietary.platforms.youtube.innertube import ( # noqa: E402
+ INNERTUBE_PUBLIC_API_KEY,
+ INNERTUBE_SEARCH_URL,
+ build_innertube_payload,
+ fetch_html,
+ post_innertube,
+)
+from app.proprietary.platforms.youtube.parsers import ( # noqa: E402
+ extract_yt_initial_data,
+ extract_yt_initial_player_response,
+)
+
+_VIDEO_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+_SEARCH_TERM = "web scraping tutorials"
+_CHANNEL_URL = "https://www.youtube.com/@YouTube"
+# A geo-tagged walking tour + a multi-owner collaboration video (long-tail fields).
+_LOCATION_VIDEO_URL = "https://www.youtube.com/watch?v=bhJU_fVHMmY"
+_COLLAB_VIDEO_URL = "https://www.youtube.com/watch?v=AI2BwwLX_7s"
+# MrBeast localizes titles/descriptions into many languages (translation flow).
+_TRANSLATED_VIDEO_URL = "https://www.youtube.com/watch?v=iYlODtkyw_I"
+
+_FIXTURE_DIR = _BACKEND_ROOT / "tests" / "unit" / "platforms" / "youtube" / "fixtures"
+
+
+def _hr(title: str) -> None:
+ print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
+
+
+def _check(label: str, ok: bool, detail: str = "") -> bool:
+ print(f" [{'PASS' if ok else 'FAIL'}] {label}{f' — {detail}' if detail else ''}")
+ return ok
+
+
+async def step0_validate_innertube() -> bool:
+ _hr("STEP 0 — InnerTube search POST (keyless, then public-key fallback)")
+ payload = build_innertube_payload(search_query=_SEARCH_TERM)
+ keyless = await post_innertube(INNERTUBE_SEARCH_URL, payload)
+ if keyless is not None:
+ return _check("keyless search POST", True, "keyless works")
+ keyed = await post_innertube(
+ INNERTUBE_SEARCH_URL, payload, api_key=INNERTUBE_PUBLIC_API_KEY
+ )
+ return _check("public-key search POST", keyed is not None, "keyless 400 -> key")
+
+
+async def step1_video() -> bool:
+ _hr("STEP 1 — scrape a known video")
+ inp = YouTubeScrapeInput(startUrls=[{"url": _VIDEO_URL}], downloadSubtitles=True)
+ items = await scrape_youtube(inp)
+ print(json.dumps(items[:1], indent=2)[:2000])
+ ok = bool(items) and items[0].get("id") == "dQw4w9WgXcQ"
+ return _check("video scraped with id + title", ok and bool(items[0].get("title")))
+
+
+async def step2_search() -> bool:
+ _hr("STEP 2 — search query")
+ inp = YouTubeScrapeInput(searchQueries=[_SEARCH_TERM], maxResults=5)
+ items = await scrape_youtube(inp)
+ for it in items[:5]:
+ print(f" - {it.get('id')} | {it.get('title')}")
+ return _check("search returned results", len(items) > 0, f"{len(items)} items")
+
+
+async def step3_channel() -> bool:
+ _hr("STEP 3 — channel latest videos")
+ inp = YouTubeScrapeInput(startUrls=[{"url": _CHANNEL_URL}], maxResults=3)
+ items = await scrape_youtube(inp)
+ for it in items[:3]:
+ print(f" - {it.get('id')} | {it.get('title')}")
+ return _check("channel returned videos", len(items) > 0, f"{len(items)} items")
+
+
+async def step4_dump_fixtures() -> bool:
+ _hr("STEP 4 — dump raw fixtures for offline test")
+ html = await fetch_html(_VIDEO_URL)
+ if not html:
+ return _check("fetched video HTML", False)
+ initial = extract_yt_initial_data(html)
+ player = extract_yt_initial_player_response(html)
+ _FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
+ if player:
+ (_FIXTURE_DIR / "video_player_response.json").write_text(
+ json.dumps(player), encoding="utf-8"
+ )
+ if initial:
+ (_FIXTURE_DIR / "video_initial_data.json").write_text(
+ json.dumps(initial), encoding="utf-8"
+ )
+ return _check("dumped fixtures", bool(player), f"-> {_FIXTURE_DIR}")
+
+
+async def step5_comments() -> bool:
+ _hr("STEP 5 — comments (+ replies) for a video")
+ inp = YouTubeCommentsInput(
+ startUrls=[{"url": _VIDEO_URL}], maxComments=6, sortCommentsBy="NEWEST_FIRST"
+ )
+ items = await scrape_comments(inp)
+ for it in items[:6]:
+ print(
+ f" - [{it.get('type')}] {it.get('author')} | {it.get('voteCount')} votes"
+ )
+ ok = bool(items) and all(it.get("cid") and it.get("videoId") for it in items)
+ has_reply = any(it.get("type") == "reply" and it.get("replyToCid") for it in items)
+ return _check(
+ "comments scraped (cid+videoId, reply linkage)",
+ ok and has_reply,
+ f"{len(items)} items",
+ )
+
+
+async def step6_location_collaborators() -> bool:
+ _hr("STEP 6 — long-tail fields: location + collaborators")
+ loc_items = await scrape_youtube(
+ YouTubeScrapeInput(startUrls=[{"url": _LOCATION_VIDEO_URL}])
+ )
+ location = loc_items[0].get("location") if loc_items else None
+ print(f" location: {location!r}")
+ collab_items = await scrape_youtube(
+ YouTubeScrapeInput(startUrls=[{"url": _COLLAB_VIDEO_URL}])
+ )
+ collaborators = collab_items[0].get("collaborators") if collab_items else None
+ print(f" collaborators: {collaborators}")
+ return _check(
+ "location + collaborators populated",
+ bool(location) and bool(collaborators) and len(collaborators) >= 2,
+ )
+
+
+async def step7_translation() -> bool:
+ _hr("STEP 7 — translatedTitle/translatedText (subtitlesLanguage=es)")
+ items = await scrape_youtube(
+ YouTubeScrapeInput(
+ startUrls=[{"url": _TRANSLATED_VIDEO_URL}], subtitlesLanguage="es"
+ )
+ )
+ it = items[0] if items else {}
+ print(f" title : {it.get('title')}")
+ print(f" translatedTitle: {it.get('translatedTitle')}")
+ # A localized video's translated title differs from the canonical English one.
+ ok = bool(it.get("translatedTitle")) and it.get("translatedTitle") != it.get(
+ "title"
+ )
+ return _check("translatedTitle differs from original", ok)
+
+
+async def main() -> int:
+ results = []
+ results.append(await step0_validate_innertube())
+ if not results[-1]:
+ print("\nInnerTube unreachable — aborting remaining steps.")
+ return 1
+ results.append(await step1_video())
+ results.append(await step2_search())
+ results.append(await step3_channel())
+ results.append(await step4_dump_fixtures())
+ results.append(await step5_comments())
+ results.append(await step6_location_collaborators())
+ results.append(await step7_translation())
+
+ _hr("SUMMARY")
+ print(f" {sum(results)}/{len(results)} steps passed")
+ return 0 if all(results) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))
diff --git a/surfsense_backend/tests/conftest.py b/surfsense_backend/tests/conftest.py
index e227ed287..9e5264062 100644
--- a/surfsense_backend/tests/conftest.py
+++ b/surfsense_backend/tests/conftest.py
@@ -37,7 +37,7 @@ def sample_user_id() -> str:
@pytest.fixture
-def sample_search_space_id() -> int:
+def sample_workspace_id() -> int:
return 1
@@ -59,7 +59,7 @@ def make_connector_document():
"source_markdown": "## Heading\n\nSome content.",
"unique_id": "test-id-001",
"document_type": DocumentType.CLICKUP_CONNECTOR,
- "search_space_id": 1,
+ "workspace_id": 1,
"connector_id": 1,
"created_by_id": "00000000-0000-0000-0000-000000000001",
}
diff --git a/surfsense_backend/tests/e2e/README.md b/surfsense_backend/tests/e2e/README.md
index caa0f89b0..30a9bcd3d 100644
--- a/surfsense_backend/tests/e2e/README.md
+++ b/surfsense_backend/tests/e2e/README.md
@@ -72,7 +72,7 @@ pnpm exec playwright install --with-deps chromium
### Each run
**1. Bring up Postgres + Redis** from the repo root (the other deps-only
-services (SearXNG, Zero, pgAdmin) are not needed for E2E):
+services (Zero, pgAdmin) are not needed for E2E):
```bash
docker compose -f docker/docker-compose.deps-only.yml up -d db redis
diff --git a/surfsense_backend/tests/e2e/fakes/chat_llm.py b/surfsense_backend/tests/e2e/fakes/chat_llm.py
index 234a18ec1..6c8d2429e 100644
--- a/surfsense_backend/tests/e2e/fakes/chat_llm.py
+++ b/surfsense_backend/tests/e2e/fakes/chat_llm.py
@@ -142,6 +142,13 @@ class FakeChatLLM(BaseChatModel):
and CLICKUP_CANARY_TOKEN in latest_tool_text
):
return f"ClickUp live tool content found: {CLICKUP_CANARY_TOKEN}"
+ if latest_tool_name == "search" and NOTION_CANARY_TOKEN in latest_tool_text:
+ return f"Notion live tool content found: {NOTION_CANARY_TOKEN}"
+ if (
+ latest_tool_name == "searchConfluenceUsingCql"
+ and CONFLUENCE_CANARY_TOKEN in latest_tool_text
+ ):
+ return f"Confluence live tool content found: {CONFLUENCE_CANARY_TOKEN}"
wants_gmail = _contains_any(
latest_human,
@@ -555,8 +562,8 @@ class FakeChatLLM(BaseChatModel):
# Marker unique to a connector subagent's prompt: the main agent must
# delegate via ``task``; only the subagent has connector tools registered.
- in_connector_subagent = (
- "specialist for the user's connected" in _messages_to_text(messages)
+ in_connector_subagent = "connected-apps specialist" in _messages_to_text(
+ messages
)
# Main agent: delegate live-tool connector work to its subagent (which
@@ -579,8 +586,12 @@ class FakeChatLLM(BaseChatModel):
("linear", ("linear", "issue", LINEAR_CANARY_TITLE)),
("slack", ("slack", SLACK_CANARY_TOKEN)),
("clickup", ("clickup", CLICKUP_CANARY_TITLE)),
+ ("notion", ("notion", NOTION_CANARY_TITLE)),
+ ("confluence", ("confluence", CONFLUENCE_CANARY_TITLE)),
)
- for subagent_type, needles in connector_delegations:
+ # Every MCP-backed connector is now one ``mcp_discovery`` route; the
+ # needle set only decides which canary the delegation targets.
+ for connector_key, needles in connector_delegations:
if _contains_any(latest_human, needles):
return AIMessage(
content="",
@@ -588,10 +599,10 @@ class FakeChatLLM(BaseChatModel):
{
"name": "task",
"args": {
- "subagent_type": subagent_type,
+ "subagent_type": "mcp_discovery",
"description": latest_human,
},
- "id": f"call_e2e_task_{subagent_type}",
+ "id": f"call_e2e_task_{connector_key}",
}
],
)
@@ -718,6 +729,38 @@ class FakeChatLLM(BaseChatModel):
],
)
+ # Confluence check precedes Notion: the Confluence prompt also contains
+ # the word "page", so Notion's needle omits it to avoid cross-matching.
+ if latest_tool is None and _contains_any(
+ latest_human,
+ ("confluence", CONFLUENCE_CANARY_TITLE),
+ ):
+ return AIMessage(
+ content="",
+ tool_calls=[
+ {
+ "name": "searchConfluenceUsingCql",
+ "args": {"cql": f'text ~ "{CONFLUENCE_CANARY_TITLE}"'},
+ "id": "call_e2e_search_confluence",
+ }
+ ],
+ )
+
+ if latest_tool is None and _contains_any(
+ latest_human,
+ ("notion", NOTION_CANARY_TITLE),
+ ):
+ return AIMessage(
+ content="",
+ tool_calls=[
+ {
+ "name": "search",
+ "args": {"query": NOTION_CANARY_TITLE},
+ "id": "call_e2e_search_notion",
+ }
+ ],
+ )
+
return None
def _generate(
diff --git a/surfsense_backend/tests/e2e/fakes/jira_module.py b/surfsense_backend/tests/e2e/fakes/jira_module.py
index d67531218..c9c03e2f1 100644
--- a/surfsense_backend/tests/e2e/fakes/jira_module.py
+++ b/surfsense_backend/tests/e2e/fakes/jira_module.py
@@ -1,4 +1,13 @@
-"""Strict Jira MCP OAuth/tool fakes for Playwright E2E."""
+"""Strict Atlassian (Jira + Confluence) MCP OAuth/tool fakes for Playwright E2E.
+
+Jira and Confluence share one hosted Atlassian Rovo MCP server
+(``https://mcp.atlassian.com/v1/mcp``) and one OAuth surface. Because the MCP
+OAuth/runtime dispatchers are keyed by URL, a single handler here serves both
+connectors; production keeps their tool sets disjoint via each service's
+``allowed_tools`` curation. The OAuth ``redirect_uri`` substring is therefore
+relaxed to the shared ``/api/v1/auth/mcp/`` prefix so both the
+``.../mcp/jira/...`` and ``.../mcp/confluence/...`` callbacks validate.
+"""
from __future__ import annotations
@@ -22,6 +31,13 @@ _ACCESS_TOKEN = "fake-jira-mcp-access-token"
_REFRESH_TOKEN = "fake-jira-mcp-refresh-token"
_OAUTH_CODE = "fake-jira-oauth-code"
+# Confluence canary — keep in sync with FAKE_CONFLUENCE_PAGES /
+# CANARY_TOKENS.confluenceCanary in surfsense_web/tests/helpers/canary.ts.
+_CONFLUENCE_TOKEN = "SURFSENSE_E2E_CANARY_TOKEN_CONFLUENCE_001"
+_CONFLUENCE_PAGE_ID = "fake-confluence-page-canary-001"
+_CONFLUENCE_TITLE = "E2E Canary Confluence Page"
+_CONFLUENCE_SPACE_ID = "fake-confluence-space-001"
+
def _load_fixture() -> dict[str, Any]:
with _FIXTURE_PATH.open() as f:
@@ -69,6 +85,34 @@ async def _list_tools() -> SimpleNamespace:
"required": ["jql"],
},
),
+ SimpleNamespace(
+ name="searchConfluenceUsingCql",
+ description="Search Confluence content using a CQL expression.",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "cql": {
+ "type": "string",
+ "description": "CQL query used to search Confluence content.",
+ }
+ },
+ "required": ["cql"],
+ },
+ ),
+ SimpleNamespace(
+ name="getConfluencePage",
+ description="Fetch a Confluence page's body by id.",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "pageId": {
+ "type": "string",
+ "description": "The Confluence page id to fetch.",
+ }
+ },
+ "required": ["pageId"],
+ },
+ ),
]
)
@@ -98,11 +142,30 @@ async def _call_tool(
text = _issue_text(issue)
return SimpleNamespace(content=[SimpleNamespace(text=text)])
- raise NotImplementedError(f"Unexpected Jira MCP tool call: {tool_name!r}")
+ if tool_name == "searchConfluenceUsingCql":
+ cql = str(arguments.get("cql", ""))
+ if _CONFLUENCE_TITLE.lower() not in cql.lower():
+ raise ValueError(f"Unexpected Confluence CQL query: {cql!r}")
+ text = (
+ f"{_CONFLUENCE_TITLE}\n"
+ f"id: {_CONFLUENCE_PAGE_ID}\n"
+ f"space: {_CONFLUENCE_SPACE_ID}\n"
+ f"excerpt: {_CONFLUENCE_TOKEN}"
+ )
+ return SimpleNamespace(content=[SimpleNamespace(text=text)])
+
+ if tool_name == "getConfluencePage":
+ page_id = str(arguments.get("pageId", ""))
+ if page_id != _CONFLUENCE_PAGE_ID:
+ raise ValueError(f"Unexpected Confluence page id: {page_id!r}")
+ text = f"{_CONFLUENCE_TITLE}\n\n{_CONFLUENCE_TOKEN}"
+ return SimpleNamespace(content=[SimpleNamespace(text=text)])
+
+ raise NotImplementedError(f"Unexpected Atlassian MCP tool call: {tool_name!r}")
def install(active_patches: list[Any]) -> None:
- """Register Jira MCP OAuth/tool handlers with the shared dispatchers."""
+ """Register the shared Atlassian (Jira + Confluence) MCP handlers."""
del active_patches
mcp_oauth_runtime.register_service(
mcp_url=_MCP_URL,
@@ -122,8 +185,10 @@ def install(active_patches: list[Any]) -> None:
oauth_code=_OAUTH_CODE,
access_token=_ACCESS_TOKEN,
refresh_token=_REFRESH_TOKEN,
- scope="read:jira-work read:me write:jira-work",
- redirect_uri_substring="/api/v1/auth/mcp/jira/connector/callback",
+ scope="read:jira-work read:confluence-content.all read:me write:jira-work",
+ # Shared Atlassian server serves both Jira and Confluence connectors, so
+ # accept either service's callback under the common MCP OAuth prefix.
+ redirect_uri_substring="/api/v1/auth/mcp/",
)
mcp_runtime.register(
url=_MCP_URL,
diff --git a/surfsense_backend/tests/e2e/fakes/native_google.py b/surfsense_backend/tests/e2e/fakes/native_google.py
index 1afcaf9c3..cdbc17828 100644
--- a/surfsense_backend/tests/e2e/fakes/native_google.py
+++ b/surfsense_backend/tests/e2e/fakes/native_google.py
@@ -430,15 +430,15 @@ def install(active_patches: list[Any]) -> None:
("app.connectors.google_gmail_connector.build", _fake_build),
("app.connectors.google_calendar_connector.build", _fake_build),
(
- "app.agents.chat.multi_agent_chat.subagents.connectors.calendar.tools.create_event.build",
+ "app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.calendar.create_event.build",
_fake_build,
),
(
- "app.agents.chat.multi_agent_chat.subagents.connectors.calendar.tools.update_event.build",
+ "app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.calendar.update_event.build",
_fake_build,
),
(
- "app.agents.chat.multi_agent_chat.subagents.connectors.calendar.tools.delete_event.build",
+ "app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.calendar.delete_event.build",
_fake_build,
),
("googleapiclient.http.MediaIoBaseDownload", _FakeMediaIoBaseDownload),
diff --git a/surfsense_backend/tests/e2e/fakes/notion_mcp_module.py b/surfsense_backend/tests/e2e/fakes/notion_mcp_module.py
new file mode 100644
index 000000000..64d615c5c
--- /dev/null
+++ b/surfsense_backend/tests/e2e/fakes/notion_mcp_module.py
@@ -0,0 +1,127 @@
+"""Strict Notion MCP OAuth/tool fakes for Playwright E2E.
+
+Notion migrated from indexed OAuth to the hosted Notion MCP server
+(``https://mcp.notion.com/mcp``, DCR/RFC 7591). This fake mirrors
+``jira_module`` for the generic MCP OAuth + streamable-HTTP tool boundaries;
+the older ``notion_module`` (a ``notion_client`` SDK stand-in) stays only to
+satisfy production's import-time ``import notion_client``.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any
+
+from tests.e2e.fakes import mcp_oauth_runtime, mcp_runtime
+
+_AUTHORIZATION_URL = "https://mcp.notion.com/authorize"
+_REGISTRATION_URL = "https://mcp.notion.com/register"
+_TOKEN_URL = "https://mcp.notion.com/token"
+_MCP_URL = "https://mcp.notion.com/mcp"
+
+_CLIENT_ID = "fake-notion-mcp-client-id"
+_CLIENT_SECRET = "fake-notion-mcp-client-secret"
+_ACCESS_TOKEN = "fake-notion-mcp-access-token"
+_REFRESH_TOKEN = "fake-notion-mcp-refresh-token"
+_OAUTH_CODE = "fake-notion-oauth-code"
+
+# Keep in sync with FAKE_NOTION_PAGES / CANARY_TOKENS.notionCanary in
+# surfsense_web/tests/helpers/canary.ts.
+_CANARY_TOKEN = "SURFSENSE_E2E_CANARY_TOKEN_NOTION_001"
+_CANARY_PAGE_ID = "fake-notion-page-canary-001"
+_CANARY_TITLE = "E2E Canary Notion Page"
+_WORKSPACE_NAME = "SurfSense E2E Notion Workspace"
+
+
+async def _list_tools() -> SimpleNamespace:
+ return SimpleNamespace(
+ tools=[
+ SimpleNamespace(
+ name="search",
+ description="Search the connected Notion workspace for pages and databases.",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Text to search Notion pages for.",
+ }
+ },
+ "required": ["query"],
+ },
+ ),
+ SimpleNamespace(
+ name="fetch",
+ description="Fetch the full contents of a Notion page by id.",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The Notion page id to fetch.",
+ }
+ },
+ "required": ["id"],
+ },
+ ),
+ ]
+ )
+
+
+async def _call_tool(
+ tool_name: str, arguments: dict[str, Any] | None = None
+) -> SimpleNamespace:
+ arguments = arguments or {}
+
+ if tool_name == "search":
+ query = str(arguments.get("query", ""))
+ if _CANARY_TITLE.lower() not in query.lower():
+ raise ValueError(f"Unexpected Notion search query: {query!r}")
+ text = (
+ f"{_CANARY_TITLE}\n"
+ f"id: {_CANARY_PAGE_ID}\n"
+ f"workspace: {_WORKSPACE_NAME}\n"
+ f"snippet: {_CANARY_TOKEN}"
+ )
+ return SimpleNamespace(content=[SimpleNamespace(text=text)])
+
+ if tool_name == "fetch":
+ page_id = str(arguments.get("id", ""))
+ if page_id != _CANARY_PAGE_ID:
+ raise ValueError(f"Unexpected Notion fetch id: {page_id!r}")
+ text = f"{_CANARY_TITLE}\n\n{_CANARY_TOKEN}"
+ return SimpleNamespace(content=[SimpleNamespace(text=text)])
+
+ raise NotImplementedError(f"Unexpected Notion MCP tool call: {tool_name!r}")
+
+
+def install(active_patches: list[Any]) -> None:
+ """Register Notion MCP OAuth/tool handlers with the shared dispatchers."""
+ del active_patches
+ mcp_oauth_runtime.register_service(
+ mcp_url=_MCP_URL,
+ discovery_metadata={
+ "issuer": "https://mcp.notion.com",
+ "authorization_endpoint": _AUTHORIZATION_URL,
+ "token_endpoint": _TOKEN_URL,
+ "registration_endpoint": _REGISTRATION_URL,
+ "code_challenge_methods_supported": ["S256"],
+ "grant_types_supported": ["authorization_code", "refresh_token"],
+ "response_types_supported": ["code"],
+ },
+ client_id=_CLIENT_ID,
+ client_secret=_CLIENT_SECRET,
+ token_endpoint=_TOKEN_URL,
+ registration_endpoint=_REGISTRATION_URL,
+ oauth_code=_OAUTH_CODE,
+ access_token=_ACCESS_TOKEN,
+ refresh_token=_REFRESH_TOKEN,
+ scope="read write",
+ redirect_uri_substring="/api/v1/auth/mcp/notion/connector/callback",
+ )
+ mcp_runtime.register(
+ url=_MCP_URL,
+ expected_bearer=_ACCESS_TOKEN,
+ list_tools=_list_tools,
+ call_tool=_call_tool,
+ )
diff --git a/surfsense_backend/tests/e2e/run_backend.py b/surfsense_backend/tests/e2e/run_backend.py
index 87977626f..c1f9aad42 100644
--- a/surfsense_backend/tests/e2e/run_backend.py
+++ b/surfsense_backend/tests/e2e/run_backend.py
@@ -282,6 +282,7 @@ def _install_runtime_fakes() -> None:
mcp_oauth_runtime as _fake_mcp_oauth_runtime,
mcp_runtime as _fake_mcp_runtime,
native_google as _fake_native_google,
+ notion_mcp_module as _fake_notion_mcp_module,
notion_module as _fake_notion_module,
onedrive_graph as _fake_onedrive_graph,
slack_module as _fake_slack_module,
@@ -295,6 +296,7 @@ def _install_runtime_fakes() -> None:
_fake_onedrive_graph.install(_active_patches)
_fake_dropbox_api.install(_active_patches)
_fake_notion_module.install(_active_patches)
+ _fake_notion_mcp_module.install(_active_patches)
_fake_linear_module.install(_active_patches)
_fake_jira_module.install(_active_patches)
_fake_clickup_module.install(_active_patches)
diff --git a/surfsense_backend/tests/integration/agents/multi_agent_chat/shared/retrieval/test_hybrid_search.py b/surfsense_backend/tests/integration/agents/multi_agent_chat/shared/retrieval/test_hybrid_search.py
index f7ba86a67..0eb204a38 100644
--- a/surfsense_backend/tests/integration/agents/multi_agent_chat/shared/retrieval/test_hybrid_search.py
+++ b/surfsense_backend/tests/integration/agents/multi_agent_chat/shared/retrieval/test_hybrid_search.py
@@ -18,7 +18,7 @@ from app.agents.chat.multi_agent_chat.shared.retrieval.hybrid_search import (
)
from app.agents.chat.multi_agent_chat.shared.retrieval.models import SearchScope
from app.config import config
-from app.db import Chunk, Document, DocumentType, SearchSpace
+from app.db import Chunk, Document, DocumentType, Workspace
pytestmark = pytest.mark.integration
@@ -35,7 +35,7 @@ def _axis(index: int) -> list[float]:
async def _add_document(
db_session,
*,
- search_space_id: int,
+ workspace_id: int,
title: str = "Doc",
document_type: DocumentType = DocumentType.FILE,
state: str = "ready",
@@ -47,7 +47,7 @@ async def _add_document(
document_type=document_type,
content="\n".join(content for content, _, _ in chunks),
content_hash=uuid.uuid4().hex,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
status={"state": state},
)
db_session.add(document)
@@ -65,17 +65,17 @@ async def _add_document(
return document
-async def test_keyword_relevant_document_is_retrieved(db_session, db_search_space):
+async def test_keyword_relevant_document_is_retrieved(db_session, db_workspace):
document = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Asyncio Guide",
chunks=[("The asyncio library enables concurrency.", 0, _axis(0))],
)
results = await search_chunks(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
query="asyncio",
scope=SearchScope(),
top_k=5,
@@ -85,23 +85,23 @@ async def test_keyword_relevant_document_is_retrieved(db_session, db_search_spac
assert document.id in {hit.document_id for hit in results}
-async def test_semantically_closest_document_ranks_first(db_session, db_search_space):
+async def test_semantically_closest_document_ranks_first(db_session, db_workspace):
aligned = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Background Work",
chunks=[("Parallel execution of background work.", 0, _axis(0))],
)
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Dessert",
chunks=[("Recipes for chocolate cake.", 0, _axis(1))],
)
results = await search_chunks(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
query="asynchronous coroutines",
scope=SearchScope(),
top_k=5,
@@ -111,25 +111,25 @@ async def test_semantically_closest_document_ranks_first(db_session, db_search_s
assert results[0].document_id == aligned.id
-async def test_results_stay_within_the_search_space(db_session, db_search_space):
- other_space = SearchSpace(name="Other Space", user_id=db_search_space.user_id)
+async def test_results_stay_within_the_workspace(db_session, db_workspace):
+ other_space = Workspace(name="Other Space", user_id=db_workspace.user_id)
db_session.add(other_space)
await db_session.flush()
mine = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
chunks=[("Shared keyword asyncio here.", 0, _axis(0))],
)
foreign = await _add_document(
db_session,
- search_space_id=other_space.id,
+ workspace_id=other_space.id,
chunks=[("Shared keyword asyncio here.", 0, _axis(0))],
)
results = await search_chunks(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
query="asyncio",
scope=SearchScope(),
top_k=5,
@@ -140,21 +140,21 @@ async def test_results_stay_within_the_search_space(db_session, db_search_space)
assert mine.id in found and foreign.id not in found
-async def test_document_ids_scope_pins_results(db_session, db_search_space):
+async def test_document_ids_scope_pins_results(db_session, db_workspace):
pinned = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
chunks=[("asyncio appears in the pinned doc.", 0, _axis(0))],
)
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
chunks=[("asyncio appears in the other doc too.", 0, _axis(0))],
)
results = await search_chunks(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
query="asyncio",
scope=SearchScope(document_ids=(pinned.id,)),
top_k=5,
@@ -164,22 +164,22 @@ async def test_document_ids_scope_pins_results(db_session, db_search_space):
assert {hit.document_id for hit in results} == {pinned.id}
-async def test_deleting_documents_are_excluded(db_session, db_search_space):
+async def test_deleting_documents_are_excluded(db_session, db_workspace):
ready = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
chunks=[("asyncio in a ready document.", 0, _axis(0))],
)
deleting = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
state="deleting",
chunks=[("asyncio in a deleting document.", 0, _axis(0))],
)
results = await search_chunks(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
query="asyncio",
scope=SearchScope(),
top_k=5,
@@ -190,12 +190,12 @@ async def test_deleting_documents_are_excluded(db_session, db_search_space):
assert ready.id in found and deleting.id not in found
-async def test_matched_chunks_are_ordered_for_reading(db_session, db_search_space):
+async def test_matched_chunks_are_ordered_for_reading(db_session, db_workspace):
# Insert out of order, and give the later-position chunk the stronger
# semantic score, so reading order differs from both insertion and score.
document = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
chunks=[
("asyncio paragraph two.", 1, _axis(0)),
("asyncio paragraph one.", 0, _axis(50)),
@@ -204,7 +204,7 @@ async def test_matched_chunks_are_ordered_for_reading(db_session, db_search_spac
results = await search_chunks(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
query="asyncio",
scope=SearchScope(),
top_k=5,
@@ -215,18 +215,18 @@ async def test_matched_chunks_are_ordered_for_reading(db_session, db_search_spac
assert [chunk.position for chunk in hit.chunks] == [0, 1]
-async def test_top_k_caps_the_number_of_documents(db_session, db_search_space):
+async def test_top_k_caps_the_number_of_documents(db_session, db_workspace):
for index in range(3):
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title=f"Doc {index}",
chunks=[(f"asyncio mentioned in doc {index}.", 0, _axis(index))],
)
results = await search_chunks(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
query="asyncio",
scope=SearchScope(),
top_k=2,
diff --git a/surfsense_backend/tests/integration/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/test_search_knowledge_base.py b/surfsense_backend/tests/integration/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/test_search_knowledge_base.py
index 09e5f0abf..2a05665cc 100644
--- a/surfsense_backend/tests/integration/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/test_search_knowledge_base.py
+++ b/surfsense_backend/tests/integration/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/test_search_knowledge_base.py
@@ -48,7 +48,7 @@ def _axis(index: int) -> list[float]:
async def _add_document(
db_session,
*,
- search_space_id: int,
+ workspace_id: int,
title: str,
text: str,
folder_id: int | None = None,
@@ -58,7 +58,7 @@ async def _add_document(
document_type=DocumentType.FILE,
content=text,
content_hash=uuid.uuid4().hex,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
folder_id=folder_id,
status={"state": "ready"},
)
@@ -71,8 +71,8 @@ async def _add_document(
return document
-async def _add_folder(db_session, *, search_space_id: int, name: str = "Folder"):
- folder = Folder(name=name, position="0", search_space_id=search_space_id)
+async def _add_folder(db_session, *, workspace_id: int, name: str = "Folder"):
+ folder = Folder(name=name, position="0", workspace_id=workspace_id)
db_session.add(folder)
await db_session.flush()
return folder
@@ -109,15 +109,15 @@ def _mentions(*, document_ids=(), folder_ids=()):
async def test_tool_returns_retrieved_context_with_numbered_passages(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Asyncio Guide",
text="The asyncio library enables concurrency.",
)
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "asyncio")
@@ -129,15 +129,15 @@ async def test_tool_returns_retrieved_context_with_numbered_passages(
async def test_tool_populates_citation_registry_on_state(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Asyncio Guide",
text="The asyncio library enables concurrency.",
)
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "asyncio")
@@ -147,15 +147,15 @@ async def test_tool_populates_citation_registry_on_state(
async def test_tool_reuses_existing_registry_numbering(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Asyncio Guide",
text="The asyncio library enables concurrency.",
)
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
first = await _invoke(tool, "asyncio")
carried = first.update["citation_registry"]
@@ -166,9 +166,9 @@ async def test_tool_reuses_existing_registry_numbering(
async def test_tool_reports_no_matches_without_touching_state(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "nonexistent-term-zzz")
@@ -177,9 +177,9 @@ async def test_tool_reports_no_matches_without_touching_state(
async def test_tool_rejects_empty_query(
- db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_workspace, _tool_uses_test_session, _pinned_embedding
):
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, " ")
@@ -188,21 +188,21 @@ async def test_tool_rejects_empty_query(
async def test_document_mention_confines_search_to_pinned_doc(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
pinned = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Pinned",
text="asyncio appears in the pinned doc.",
)
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Other",
text="asyncio appears in the other doc.",
)
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "asyncio", context=_mentions(document_ids=[pinned.id]))
@@ -213,23 +213,23 @@ async def test_document_mention_confines_search_to_pinned_doc(
async def test_folder_mention_confines_search_to_folder_documents(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
- folder = await _add_folder(db_session, search_space_id=db_search_space.id)
+ folder = await _add_folder(db_session, workspace_id=db_workspace.id)
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Inside",
text="asyncio appears inside the folder.",
folder_id=folder.id,
)
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Outside",
text="asyncio appears outside the folder.",
)
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "asyncio", context=_mentions(folder_ids=[folder.id]))
@@ -240,7 +240,7 @@ async def test_folder_mention_confines_search_to_folder_documents(
async def test_document_mention_via_state_confines_search(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
"""The real subagent path: mentions arrive on ``runtime.state`` (no context).
@@ -250,17 +250,17 @@ async def test_document_mention_via_state_confines_search(
"""
pinned = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Pinned",
text="asyncio appears in the pinned doc.",
)
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Other",
text="asyncio appears in the other doc.",
)
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(
tool,
@@ -275,24 +275,24 @@ async def test_document_mention_via_state_confines_search(
async def test_folder_mention_via_state_confines_search(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
"""Folder pins delivered via state (subagent path) scope to the folder's docs."""
- folder = await _add_folder(db_session, search_space_id=db_search_space.id)
+ folder = await _add_folder(db_session, workspace_id=db_workspace.id)
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Inside",
text="asyncio appears inside the folder.",
folder_id=folder.id,
)
await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Outside",
text="asyncio appears outside the folder.",
)
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(
tool,
@@ -307,22 +307,22 @@ async def test_folder_mention_via_state_confines_search(
async def test_state_mentions_take_precedence_over_context(
- db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
+ db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
"""When both carry pins, state wins (the forwarded subagent pin is authoritative)."""
state_doc = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="StatePinned",
text="asyncio appears in the state-pinned doc.",
)
context_doc = await _add_document(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="ContextPinned",
text="asyncio appears in the context-pinned doc.",
)
- tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
+ tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(
tool,
diff --git a/surfsense_backend/tests/integration/agents/multi_agent_chat/test_agent_turn.py b/surfsense_backend/tests/integration/agents/multi_agent_chat/test_agent_turn.py
index d45570484..59b9d2161 100644
--- a/surfsense_backend/tests/integration/agents/multi_agent_chat/test_agent_turn.py
+++ b/surfsense_backend/tests/integration/agents/multi_agent_chat/test_agent_turn.py
@@ -35,18 +35,18 @@ def _last_ai_text(messages: list) -> str | None:
@pytest.mark.asyncio
-async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_search_space):
+async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_workspace):
"""A freshly assembled agent streams a scripted final-text turn to completion."""
harness = build_scripted_harness(turns=[ScriptedTurn(text="done")])
agent = await create_multi_agent_chat_deep_agent(
llm=harness.model,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
db_session=db_session,
connector_service=ConnectorService(db_session),
checkpointer=InMemorySaver(),
user_id=str(db_user.id),
- thread_id=db_search_space.id,
+ thread_id=db_workspace.id,
agent_config=None,
)
@@ -59,7 +59,7 @@ async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_search_sp
@pytest.mark.asyncio
-async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_space):
+async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_workspace):
"""The compiled graph routes a model tool call to its tool and resumes."""
harness = build_scripted_harness(
turns=[
@@ -79,12 +79,12 @@ async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_
agent = await create_multi_agent_chat_deep_agent(
llm=harness.model,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
db_session=db_session,
connector_service=ConnectorService(db_session),
checkpointer=InMemorySaver(),
user_id=str(db_user.id),
- thread_id=db_search_space.id,
+ thread_id=db_workspace.id,
agent_config=None,
additional_tools=harness.tools,
)
@@ -101,7 +101,7 @@ async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_
@pytest.mark.asyncio
async def test_agent_checkpoint_round_trips_across_turns(
- db_session, db_user, db_search_space
+ db_session, db_user, db_workspace
):
"""Turn 2 sees turn 1's history, proving the checkpoint serializes and reloads.
@@ -118,12 +118,12 @@ async def test_agent_checkpoint_round_trips_across_turns(
async def _build():
return await create_multi_agent_chat_deep_agent(
llm=harness.model,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
db_session=db_session,
connector_service=ConnectorService(db_session),
checkpointer=checkpointer,
user_id=str(db_user.id),
- thread_id=db_search_space.id,
+ thread_id=db_workspace.id,
agent_config=None,
)
diff --git a/surfsense_backend/tests/integration/agents/multi_agent_chat/test_kb_filesystem_cloud.py b/surfsense_backend/tests/integration/agents/multi_agent_chat/test_kb_filesystem_cloud.py
index 878473f55..c73382705 100644
--- a/surfsense_backend/tests/integration/agents/multi_agent_chat/test_kb_filesystem_cloud.py
+++ b/surfsense_backend/tests/integration/agents/multi_agent_chat/test_kb_filesystem_cloud.py
@@ -40,16 +40,16 @@ _SEARCH_SPACE_ID = 1
def _build_cloud_fs_mw():
"""Build the production filesystem middleware in cloud mode.
- A non-None ``search_space_id`` makes the resolver hand out a
+ A non-None ``workspace_id`` makes the resolver hand out a
``KBPostgresBackend``, exactly as production does. Staging operations never
touch the DB, so a dummy id is sufficient for these tests.
"""
selection = FilesystemSelection(mode=FilesystemMode.CLOUD)
- resolver = build_backend_resolver(selection, search_space_id=_SEARCH_SPACE_ID)
+ resolver = build_backend_resolver(selection, workspace_id=_SEARCH_SPACE_ID)
return build_filesystem_mw(
backend_resolver=resolver,
filesystem_mode=FilesystemMode.CLOUD,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id="00000000-0000-0000-0000-000000000001",
thread_id=_SEARCH_SPACE_ID,
read_only=False,
diff --git a/surfsense_backend/tests/integration/agents/multi_agent_chat/test_kb_filesystem_desktop.py b/surfsense_backend/tests/integration/agents/multi_agent_chat/test_kb_filesystem_desktop.py
index e013ef35b..586dbdfb6 100644
--- a/surfsense_backend/tests/integration/agents/multi_agent_chat/test_kb_filesystem_desktop.py
+++ b/surfsense_backend/tests/integration/agents/multi_agent_chat/test_kb_filesystem_desktop.py
@@ -51,7 +51,7 @@ def _build_desktop_fs_mw(root: Path):
return build_filesystem_mw(
backend_resolver=resolver,
filesystem_mode=FilesystemMode.DESKTOP_LOCAL_FOLDER,
- search_space_id=1,
+ workspace_id=1,
user_id="00000000-0000-0000-0000-000000000001",
thread_id=1,
read_only=False,
diff --git a/surfsense_backend/tests/integration/agents/multi_agent_chat/test_web_search_delegation.py b/surfsense_backend/tests/integration/agents/multi_agent_chat/test_web_search_delegation.py
new file mode 100644
index 000000000..70d624089
--- /dev/null
+++ b/surfsense_backend/tests/integration/agents/multi_agent_chat/test_web_search_delegation.py
@@ -0,0 +1,82 @@
+"""Backend E2E: public web search now flows through the ``google_search`` route.
+
+After the Google-only consolidation the main agent has no ``web_search`` tool;
+a web query must be delegated to the ``google_search`` subagent via ``task``.
+This drives the *assembled* production graph (real DB, scripted LLM) end to end:
+main agent emits a ``task(google_search, ...)`` call, the subagent runs a turn,
+and the main agent resumes to a final answer. Proves the delegation path the
+teardown relies on actually executes -- not just that the constants changed.
+"""
+
+from __future__ import annotations
+
+import pytest
+from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
+from langgraph.checkpoint.memory import InMemorySaver
+
+from app.agents.chat.multi_agent_chat import create_multi_agent_chat_deep_agent
+from app.services.connector_service import ConnectorService
+from tests.integration.harness import ScriptedTurn, build_scripted_harness
+
+pytestmark = pytest.mark.integration
+
+
+def _last_ai_text(messages: list) -> str | None:
+ for m in reversed(messages):
+ if isinstance(m, AIMessage) and isinstance(m.content, str) and m.content:
+ return m.content
+ return None
+
+
+@pytest.mark.asyncio
+async def test_web_query_delegates_to_google_search(db_session, db_user, db_workspace):
+ """A web-search query routes through ``task(google_search)`` and resumes.
+
+ Scripted sequence (the fake model is shared and consumed in order across the
+ main agent and the delegated subagent):
+ 1. main agent -> task(subagent_type="google_search")
+ 2. subagent -> plain text (never touches the scrape tool, so no network)
+ 3. main agent -> final answer
+ """
+ harness = build_scripted_harness(
+ turns=[
+ ScriptedTurn(
+ tool_calls=[
+ {
+ "name": "task",
+ "args": {
+ "subagent_type": "google_search",
+ "description": "latest SurfSense release notes",
+ },
+ "id": "call_ws_task",
+ }
+ ]
+ ),
+ ScriptedTurn(text="SERP: SurfSense v2 shipped Google-only web search."),
+ ScriptedTurn(text="SurfSense v2 shipped Google-only web search."),
+ ]
+ )
+
+ agent = await create_multi_agent_chat_deep_agent(
+ llm=harness.model,
+ workspace_id=db_workspace.id,
+ db_session=db_session,
+ connector_service=ConnectorService(db_session),
+ checkpointer=InMemorySaver(),
+ user_id=str(db_user.id),
+ thread_id=db_workspace.id,
+ agent_config=None,
+ )
+
+ result = await agent.ainvoke(
+ {"messages": [HumanMessage(content="search the web for SurfSense news")]},
+ config={"configurable": {"thread_id": "ws-google-delegation-1"}},
+ )
+
+ task_tool_messages = [
+ m for m in result["messages"] if isinstance(m, ToolMessage) and m.name == "task"
+ ]
+ assert task_tool_messages, "web query did not delegate through the task tool"
+ assert _last_ai_text(result["messages"]) == (
+ "SurfSense v2 shipped Google-only web search."
+ )
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/__init__.py b/surfsense_backend/tests/integration/automations/__init__.py
similarity index 100%
rename from surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/__init__.py
rename to surfsense_backend/tests/integration/automations/__init__.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/__init__.py b/surfsense_backend/tests/integration/automations/actions/__init__.py
similarity index 100%
rename from surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/__init__.py
rename to surfsense_backend/tests/integration/automations/actions/__init__.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/jira/__init__.py b/surfsense_backend/tests/integration/automations/actions/builtin/__init__.py
similarity index 100%
rename from surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/jira/__init__.py
rename to surfsense_backend/tests/integration/automations/actions/builtin/__init__.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/linear/__init__.py b/surfsense_backend/tests/integration/automations/api/__init__.py
similarity index 100%
rename from surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/linear/__init__.py
rename to surfsense_backend/tests/integration/automations/api/__init__.py
diff --git a/surfsense_backend/tests/integration/automations/conftest.py b/surfsense_backend/tests/integration/automations/conftest.py
new file mode 100644
index 000000000..62420d93d
--- /dev/null
+++ b/surfsense_backend/tests/integration/automations/conftest.py
@@ -0,0 +1,66 @@
+"""Shared fixtures for automations integration tests.
+
+Bridges the code-under-test to the transactional ``db_session`` so real
+behavior runs against real Postgres and rolls back at test end:
+
+* ``client`` — httpx over ASGI with ``get_async_session``/``get_auth_context``
+ overridden to the test session + owner.
+* ``enqueue_spy`` — capture ``automation_run_execute.apply_async`` so run-now
+ can be asserted without a Redis broker.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+import httpx
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.app import app
+from app.auth.context import AuthContext
+from app.db import User, get_async_session
+from app.users import get_auth_context
+
+
+@pytest_asyncio.fixture
+async def client(
+ db_session: AsyncSession, db_user: User
+) -> AsyncGenerator[httpx.AsyncClient, None]:
+ async def override_session() -> AsyncGenerator[AsyncSession, None]:
+ yield db_session
+
+ async def override_auth() -> AuthContext:
+ return AuthContext.session(db_user)
+
+ previous = app.dependency_overrides.copy()
+ app.dependency_overrides[get_async_session] = override_session
+ app.dependency_overrides[get_auth_context] = override_auth
+ try:
+ async with httpx.AsyncClient(
+ transport=ASGITransport(app=app),
+ base_url="http://test",
+ timeout=30.0,
+ follow_redirects=False,
+ ) as c:
+ yield c
+ finally:
+ app.dependency_overrides.clear()
+ app.dependency_overrides.update(previous)
+
+
+@pytest.fixture
+def enqueue_spy(monkeypatch) -> list[dict]:
+ """Capture Celery enqueues so run-now needs no broker."""
+ import app.automations.dispatch.launch as launch_mod
+
+ calls: list[dict] = []
+
+ def _spy(*args, **kwargs):
+ calls.append({"args": args, "kwargs": kwargs})
+ return None
+
+ monkeypatch.setattr(launch_mod.automation_run_execute, "apply_async", _spy)
+ return calls
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/__init__.py b/surfsense_backend/tests/integration/automations/services/__init__.py
similarity index 100%
rename from surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/__init__.py
rename to surfsense_backend/tests/integration/automations/services/__init__.py
diff --git a/surfsense_backend/tests/integration/automations/test_checkpointer_cross_loop.py b/surfsense_backend/tests/integration/automations/test_checkpointer_cross_loop.py
new file mode 100644
index 000000000..9e3f32a28
--- /dev/null
+++ b/surfsense_backend/tests/integration/automations/test_checkpointer_cross_loop.py
@@ -0,0 +1,61 @@
+"""The durable checkpointer survives Celery's fresh-loop-per-task model.
+
+Slice 1's whole point: the shared ``AsyncPostgresSaver`` pool binds connections
+to the loop that opened them, and Celery runs each task on a new loop. This
+writes a checkpoint on one ``run_async_celery_task`` loop and reads it back on a
+*fresh* one — proving the per-task pool dispose lets a new loop reopen and read
+committed state, rather than stalling on a stale connection.
+
+Uses the real pool against the real (test) Postgres, so a regression in the
+dispose wiring fails here, not just in production.
+"""
+
+from __future__ import annotations
+
+import uuid
+
+import pytest
+from langgraph.checkpoint.base import empty_checkpoint
+
+from app.agents.chat.runtime.checkpointer import close_checkpointer, get_checkpointer
+from app.tasks.celery_tasks import run_async_celery_task
+
+pytestmark = pytest.mark.integration
+
+
+def _config(thread_id: str) -> dict:
+ return {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
+
+
+def test_checkpoint_written_on_one_loop_is_readable_on_a_fresh_loop() -> None:
+ thread_id = f"cross-loop-{uuid.uuid4()}"
+ config = _config(thread_id)
+ checkpoint = empty_checkpoint()
+
+ async def _write() -> None:
+ cp = await get_checkpointer()
+ await cp.aput(config, checkpoint, {"source": "update", "step": 0}, {})
+
+ async def _read():
+ cp = await get_checkpointer()
+ return await cp.aget_tuple(config)
+
+ async def _cleanup() -> None:
+ cp = await get_checkpointer()
+ delete = getattr(cp, "adelete_thread", None)
+ if delete is not None:
+ await delete(thread_id)
+
+ # Loop 1 writes and commits; run_async_celery_task disposes the pool after.
+ run_async_celery_task(_write)
+
+ # Loop 2 is a brand-new event loop: a stale loop-bound pool would stall
+ # here (PoolTimeout). It must reopen and read the committed checkpoint.
+ tup = run_async_celery_task(_read)
+
+ try:
+ assert tup is not None, "fresh loop could not read the prior checkpoint"
+ assert tup.checkpoint["id"] == checkpoint["id"]
+ finally:
+ run_async_celery_task(_cleanup)
+ run_async_celery_task(lambda: close_checkpointer())
diff --git a/surfsense_backend/tests/integration/chat/test_append_message_recovery.py b/surfsense_backend/tests/integration/chat/test_append_message_recovery.py
index c6a40c356..4c866f5a5 100644
--- a/surfsense_backend/tests/integration/chat/test_append_message_recovery.py
+++ b/surfsense_backend/tests/integration/chat/test_append_message_recovery.py
@@ -46,9 +46,9 @@ from app.db import (
NewChatMessage,
NewChatMessageRole,
NewChatThread,
- SearchSpace,
TokenUsage,
User,
+ Workspace,
)
from app.routes import new_chat_routes
from app.services.token_tracking_service import TurnTokenAccumulator
@@ -69,11 +69,11 @@ pytestmark = pytest.mark.integration
@pytest_asyncio.fixture
async def db_thread(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread:
thread = NewChatThread(
title="Test Chat",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
@@ -109,7 +109,7 @@ def bypass_permission_checks(monkeypatch):
"""Replace RBAC + thread access checks with no-ops.
The append_message route under test calls ``check_permission`` and
- ``check_thread_access``; those rely on a SearchSpaceMembership row
+ ``check_thread_access``; those rely on a WorkspaceMembership row
that the existing integration fixtures don't create. The contract
we want to verify here is the ``IntegrityError`` -> recovery branch,
not the RBAC plumbing — so stub them.
@@ -208,7 +208,7 @@ class TestToolHeavyTurnFinalize:
db_session,
db_user,
db_thread,
- db_search_space,
+ db_workspace,
patched_shielded_session,
):
"""End-to-end seam: builder snapshot -> finalize -> DB row.
@@ -221,7 +221,7 @@ class TestToolHeavyTurnFinalize:
"""
thread_id = db_thread.id
user_id_str = str(db_user.id)
- search_space_id = db_search_space.id
+ workspace_id = db_workspace.id
turn_id = f"{thread_id}:tool_heavy"
msg_id = await persist_assistant_shell(
@@ -258,7 +258,7 @@ class TestToolHeavyTurnFinalize:
await finalize_assistant_turn(
message_id=msg_id,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id=turn_id,
content=snapshot,
@@ -300,7 +300,7 @@ class TestToolHeavyTurnFinalize:
assert usage.total_tokens == 280
assert usage.cost_micros == 22222
assert usage.thread_id == thread_id
- assert usage.search_space_id == search_space_id
+ assert usage.workspace_id == workspace_id
# ---------------------------------------------------------------------------
@@ -314,7 +314,7 @@ class TestAppendMessageRecoveryAfterFinalize:
db_session,
db_user,
db_thread,
- db_search_space,
+ db_workspace,
patched_shielded_session,
bypass_permission_checks,
):
@@ -337,7 +337,7 @@ class TestAppendMessageRecoveryAfterFinalize:
"""
thread_id = db_thread.id
user_id_str = str(db_user.id)
- search_space_id = db_search_space.id
+ workspace_id = db_workspace.id
turn_id = f"{thread_id}:fe_late_append"
# Step 1: server stream completes. Server-built rich content is
@@ -353,7 +353,7 @@ class TestAppendMessageRecoveryAfterFinalize:
await finalize_assistant_turn(
message_id=msg_id,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id=turn_id,
content=server_content,
@@ -439,7 +439,7 @@ class TestAppendMessageRecoveryAfterFinalize:
db_session,
db_user,
db_thread,
- db_search_space,
+ db_workspace,
patched_shielded_session,
bypass_permission_checks,
):
@@ -456,7 +456,7 @@ class TestAppendMessageRecoveryAfterFinalize:
"""
thread_id = db_thread.id
user_id_str = str(db_user.id)
- search_space_id = db_search_space.id
+ workspace_id = db_workspace.id
turn_id = f"{thread_id}:fe_first"
# Step 1: legacy FE appendMessage lands first. No prior shell
@@ -490,7 +490,7 @@ class TestAppendMessageRecoveryAfterFinalize:
await finalize_assistant_turn(
message_id=adopted_id,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id=turn_id,
content=server_content,
diff --git a/surfsense_backend/tests/integration/chat/test_message_id_sse.py b/surfsense_backend/tests/integration/chat/test_message_id_sse.py
index 8fc935eaa..d1631215e 100644
--- a/surfsense_backend/tests/integration/chat/test_message_id_sse.py
+++ b/surfsense_backend/tests/integration/chat/test_message_id_sse.py
@@ -48,8 +48,8 @@ from app.db import (
NewChatMessage,
NewChatMessageRole,
NewChatThread,
- SearchSpace,
User,
+ Workspace,
)
from app.services.new_streaming_service import VercelStreamingService
from app.tasks.chat import persistence as persistence_module
@@ -68,11 +68,11 @@ pytestmark = pytest.mark.integration
@pytest_asyncio.fixture
async def db_thread(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread:
thread = NewChatThread(
title="Test Chat",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
diff --git a/surfsense_backend/tests/integration/chat/test_persistence.py b/surfsense_backend/tests/integration/chat/test_persistence.py
index d6f816cc0..b79946ade 100644
--- a/surfsense_backend/tests/integration/chat/test_persistence.py
+++ b/surfsense_backend/tests/integration/chat/test_persistence.py
@@ -38,9 +38,9 @@ from app.db import (
NewChatMessage,
NewChatMessageRole,
NewChatThread,
- SearchSpace,
TokenUsage,
User,
+ Workspace,
)
from app.services.token_tracking_service import TurnTokenAccumulator
from app.tasks.chat import persistence as persistence_module
@@ -60,11 +60,11 @@ pytestmark = pytest.mark.integration
@pytest_asyncio.fixture
async def db_thread(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread:
thread = NewChatThread(
title="Test Chat",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
@@ -537,13 +537,13 @@ class TestFinalizeAssistantTurn:
db_session,
db_user,
db_thread,
- db_search_space,
+ db_workspace,
patched_shielded_session,
):
thread_id = db_thread.id
user_id_uuid = db_user.id
user_id_str = str(user_id_uuid)
- search_space_id = db_search_space.id
+ workspace_id = db_workspace.id
turn_id = f"{thread_id}:4000"
msg_id = await persist_assistant_shell(
@@ -568,7 +568,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn(
message_id=msg_id,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id=turn_id,
content=rich_content,
@@ -597,19 +597,19 @@ class TestFinalizeAssistantTurn:
assert usage.total_tokens == 150
assert usage.cost_micros == 12345
assert usage.thread_id == thread_id
- assert usage.search_space_id == search_space_id
+ assert usage.workspace_id == workspace_id
async def test_empty_content_writes_status_marker(
self,
db_session,
db_user,
db_thread,
- db_search_space,
+ db_workspace,
patched_shielded_session,
):
thread_id = db_thread.id
user_id_str = str(db_user.id)
- search_space_id = db_search_space.id
+ workspace_id = db_workspace.id
turn_id = f"{thread_id}:5000"
msg_id = await persist_assistant_shell(
@@ -624,7 +624,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn(
message_id=msg_id,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id=turn_id,
content=[],
@@ -640,12 +640,12 @@ class TestFinalizeAssistantTurn:
db_session,
db_user,
db_thread,
- db_search_space,
+ db_workspace,
patched_shielded_session,
):
thread_id = db_thread.id
user_id_str = str(db_user.id)
- search_space_id = db_search_space.id
+ workspace_id = db_workspace.id
turn_id = f"{thread_id}:6000"
msg_id = await persist_assistant_shell(
@@ -659,7 +659,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn(
message_id=msg_id,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id=turn_id,
content=[{"type": "text", "text": "first finalize"}],
@@ -681,7 +681,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn(
message_id=msg_id,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id=turn_id,
content=[{"type": "text", "text": "second finalize"}],
@@ -708,7 +708,7 @@ class TestFinalizeAssistantTurn:
db_session,
db_user,
db_thread,
- db_search_space,
+ db_workspace,
patched_shielded_session,
):
"""Cross-writer race: ``append_message`` arrives after ``finalize_assistant_turn``.
@@ -722,7 +722,7 @@ class TestFinalizeAssistantTurn:
thread_id = db_thread.id
user_uuid = db_user.id
user_id_str = str(user_uuid)
- search_space_id = db_search_space.id
+ workspace_id = db_workspace.id
turn_id = f"{thread_id}:7000"
msg_id = await persist_assistant_shell(
@@ -735,7 +735,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn(
message_id=msg_id,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id=turn_id,
content=[{"type": "text", "text": "from server"}],
@@ -758,7 +758,7 @@ class TestFinalizeAssistantTurn:
call_details=None,
thread_id=thread_id,
message_id=msg_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_uuid,
)
.on_conflict_do_nothing(
@@ -783,19 +783,19 @@ class TestFinalizeAssistantTurn:
db_session,
db_user,
db_thread,
- db_search_space,
+ db_workspace,
patched_shielded_session,
):
thread_id = db_thread.id
user_id_str = str(db_user.id)
- search_space_id = db_search_space.id
+ workspace_id = db_workspace.id
# message_id that doesn't exist — finalize must log+return,
# never raise (called from shielded finally).
await finalize_assistant_turn(
message_id=999_999_999,
chat_id=thread_id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
user_id=user_id_str,
turn_id="anything",
content=[{"type": "text", "text": "x"}],
diff --git a/surfsense_backend/tests/integration/chat/test_thread_visibility.py b/surfsense_backend/tests/integration/chat/test_thread_visibility.py
index ba6f2a66f..888f31439 100644
--- a/surfsense_backend/tests/integration/chat/test_thread_visibility.py
+++ b/surfsense_backend/tests/integration/chat/test_thread_visibility.py
@@ -2,7 +2,7 @@
These tests exercise the route handlers directly with real DB-backed
users, memberships, and permissions. The important contract is that a
-thread shared with a search space stays shared across normal metadata
+thread shared with a workspace stays shared across normal metadata
updates until the creator explicitly makes it private again.
"""
@@ -19,10 +19,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import (
ChatVisibility,
- SearchSpace,
- SearchSpaceMembership,
- SearchSpaceRole,
User,
+ Workspace,
+ WorkspaceMembership,
+ WorkspaceRole,
)
from app.routes import new_chat_routes
from app.schemas.new_chat import (
@@ -39,7 +39,7 @@ def _auth(user: User) -> AuthContext:
@pytest_asyncio.fixture
-async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> User:
+async def db_member(db_session: AsyncSession, db_workspace: Workspace) -> User:
member = User(
id=uuid.uuid4(),
email="member@surfsense.net",
@@ -54,9 +54,9 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
role = (
(
await db_session.execute(
- select(SearchSpaceRole).where(
- SearchSpaceRole.search_space_id == db_search_space.id,
- SearchSpaceRole.name == "Editor",
+ select(WorkspaceRole).where(
+ WorkspaceRole.workspace_id == db_workspace.id,
+ WorkspaceRole.name == "Editor",
)
)
)
@@ -64,9 +64,9 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
.one()
)
db_session.add(
- SearchSpaceMembership(
+ WorkspaceMembership(
user_id=member.id,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
role_id=role.id,
is_owner=False,
)
@@ -78,7 +78,7 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
async def _create_thread(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
*,
title: str = "Visibility Invariant Chat",
):
@@ -86,7 +86,7 @@ async def _create_thread(
NewChatThreadCreate(
title=title,
archived=False,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
visibility=ChatVisibility.PRIVATE,
),
session=db_session,
@@ -102,21 +102,21 @@ def _search_thread_ids(response) -> set[int]:
return {thread.id for thread in response}
-async def test_private_thread_is_hidden_from_other_search_space_member(
+async def test_private_thread_is_hidden_from_other_workspace_member(
db_session: AsyncSession,
db_user: User,
db_member: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- thread = await _create_thread(db_session, db_user, db_search_space)
+ thread = await _create_thread(db_session, db_user, db_workspace)
member_threads = await new_chat_routes.list_threads(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
session=db_session,
auth=_auth(db_member),
)
member_search = await new_chat_routes.search_threads(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Visibility",
session=db_session,
auth=_auth(db_member),
@@ -137,9 +137,9 @@ async def test_creator_can_share_thread_and_member_can_list_search_read_it(
db_session: AsyncSession,
db_user: User,
db_member: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- thread = await _create_thread(db_session, db_user, db_search_space)
+ thread = await _create_thread(db_session, db_user, db_workspace)
updated = await new_chat_routes.update_thread_visibility(
thread_id=thread.id,
@@ -151,12 +151,12 @@ async def test_creator_can_share_thread_and_member_can_list_search_read_it(
)
member_threads = await new_chat_routes.list_threads(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
session=db_session,
auth=_auth(db_member),
)
member_search = await new_chat_routes.search_threads(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Visibility",
session=db_session,
auth=_auth(db_member),
@@ -177,9 +177,9 @@ async def test_creator_can_share_thread_and_member_can_list_search_read_it(
async def test_rename_and_archive_do_not_reset_shared_visibility(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- thread = await _create_thread(db_session, db_user, db_search_space)
+ thread = await _create_thread(db_session, db_user, db_workspace)
await new_chat_routes.update_thread_visibility(
thread_id=thread.id,
visibility_update=NewChatThreadVisibilityUpdate(
@@ -211,9 +211,9 @@ async def test_non_creator_cannot_change_shared_thread_back_to_private(
db_session: AsyncSession,
db_user: User,
db_member: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- thread = await _create_thread(db_session, db_user, db_search_space)
+ thread = await _create_thread(db_session, db_user, db_workspace)
await new_chat_routes.update_thread_visibility(
thread_id=thread.id,
visibility_update=NewChatThreadVisibilityUpdate(
@@ -240,9 +240,9 @@ async def test_creator_can_make_shared_thread_private_again(
db_session: AsyncSession,
db_user: User,
db_member: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- thread = await _create_thread(db_session, db_user, db_search_space)
+ thread = await _create_thread(db_session, db_user, db_workspace)
await new_chat_routes.update_thread_visibility(
thread_id=thread.id,
visibility_update=NewChatThreadVisibilityUpdate(
@@ -261,12 +261,12 @@ async def test_creator_can_make_shared_thread_private_again(
auth=_auth(db_user),
)
member_threads = await new_chat_routes.list_threads(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
session=db_session,
auth=_auth(db_member),
)
member_search = await new_chat_routes.search_threads(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Visibility",
session=db_session,
auth=_auth(db_member),
diff --git a/surfsense_backend/tests/integration/composio/conftest.py b/surfsense_backend/tests/integration/composio/conftest.py
index 578b5b228..66d729d83 100644
--- a/surfsense_backend/tests/integration/composio/conftest.py
+++ b/surfsense_backend/tests/integration/composio/conftest.py
@@ -65,7 +65,7 @@ async def client(
async def drive_connector(
db_session: AsyncSession,
db_user: User,
- db_search_space,
+ db_workspace,
) -> SearchSourceConnector:
connector = SearchSourceConnector(
name="Google Drive (Composio) - e2e-fake@surfsense.example",
@@ -77,7 +77,7 @@ async def drive_connector(
"toolkit_name": "Google Drive",
"is_indexable": True,
},
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=db_user.id,
)
db_session.add(connector)
diff --git a/surfsense_backend/tests/integration/composio/test_oauth_callback.py b/surfsense_backend/tests/integration/composio/test_oauth_callback.py
index d2f4c3752..5fdc3d51e 100644
--- a/surfsense_backend/tests/integration/composio/test_oauth_callback.py
+++ b/surfsense_backend/tests/integration/composio/test_oauth_callback.py
@@ -9,8 +9,8 @@ from app.config import config
from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
- SearchSpace,
User,
+ Workspace,
)
from app.utils.oauth_security import OAuthStateManager
@@ -29,12 +29,12 @@ async def _drive_connectors(
session: AsyncSession,
*,
user_id: UUID,
- search_space_id: int,
+ workspace_id: int,
) -> list[SearchSourceConnector]:
result = await session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.search_space_id == search_space_id,
+ SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
)
@@ -46,9 +46,9 @@ async def test_callback_with_error_param_redirects_to_denied_page(
client: httpx.AsyncClient,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- state = _state_for(db_search_space.id, db_user.id)
+ state = _state_for(db_workspace.id, db_user.id)
response = await client.get(
f"/api/v1/auth/composio/connector/callback?state={state}&error=access_denied"
@@ -57,14 +57,13 @@ async def test_callback_with_error_param_redirects_to_denied_page(
assert response.status_code in {302, 303, 307}
location = response.headers["location"]
assert (
- f"/dashboard/{db_search_space.id}/connectors/callback?"
- "error=composio_oauth_denied"
+ f"/dashboard/{db_workspace.id}/connectors/callback?error=composio_oauth_denied"
) in location
connectors = await _drive_connectors(
db_session,
user_id=db_user.id,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
assert connectors == []
@@ -73,9 +72,9 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
client: httpx.AsyncClient,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- first_state = _state_for(db_search_space.id, db_user.id)
+ first_state = _state_for(db_workspace.id, db_user.id)
first_response = await client.get(
"/api/v1/auth/composio/connector/callback"
@@ -86,7 +85,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
first_connectors = await _drive_connectors(
db_session,
user_id=db_user.id,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
assert len(first_connectors) == 1
first_connector = first_connectors[0]
@@ -94,7 +93,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
"fake-acct-googledrive-first"
)
- second_state = _state_for(db_search_space.id, db_user.id)
+ second_state = _state_for(db_workspace.id, db_user.id)
second_response = await client.get(
"/api/v1/auth/composio/connector/callback"
f"?state={second_state}&connectedAccountId=fake-acct-googledrive-second"
@@ -104,7 +103,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
second_connectors = await _drive_connectors(
db_session,
user_id=db_user.id,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
assert len(second_connectors) == 1
assert second_connectors[0].id == first_connector.id
diff --git a/surfsense_backend/tests/integration/conftest.py b/surfsense_backend/tests/integration/conftest.py
index 6b8aa3cdb..efd8454ac 100644
--- a/surfsense_backend/tests/integration/conftest.py
+++ b/surfsense_backend/tests/integration/conftest.py
@@ -21,13 +21,13 @@ Base = app_db.Base
DocumentType = app_db.DocumentType
SearchSourceConnector = app_db.SearchSourceConnector
SearchSourceConnectorType = app_db.SearchSourceConnectorType
-SearchSpace = app_db.SearchSpace
+Workspace = app_db.Workspace
User = app_db.User
ConnectorDocument = importlib.import_module(
"app.indexing_pipeline.connector_document"
).ConnectorDocument
create_default_roles_and_membership = importlib.import_module(
- "app.routes.search_spaces_routes"
+ "app.routes.workspaces_routes"
).create_default_roles_and_membership
TEST_DATABASE_URL = importlib.import_module("tests.conftest").TEST_DATABASE_URL
@@ -95,13 +95,13 @@ async def db_user(db_session: AsyncSession) -> User:
@pytest_asyncio.fixture
async def db_connector(
- db_session: AsyncSession, db_user: User, db_search_space: "SearchSpace"
+ db_session: AsyncSession, db_user: User, db_workspace: "Workspace"
) -> SearchSourceConnector:
connector = SearchSourceConnector(
name="Test Connector",
connector_type=SearchSourceConnectorType.CLICKUP_CONNECTOR,
config={},
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=db_user.id,
)
db_session.add(connector)
@@ -110,14 +110,14 @@ async def db_connector(
@pytest_asyncio.fixture
-async def db_search_space(db_session: AsyncSession, db_user: User) -> SearchSpace:
- space = SearchSpace(
+async def db_workspace(db_session: AsyncSession, db_user: User) -> Workspace:
+ space = Workspace(
name="Test Space",
user_id=db_user.id,
)
db_session.add(space)
await db_session.flush()
- # Mirror POST /searchspaces so routes guarded by check_permission find a membership.
+ # Mirror POST /workspaces so routes guarded by check_permission find a membership.
await create_default_roles_and_membership(db_session, space.id, db_user.id)
await db_session.flush()
return space
@@ -180,7 +180,7 @@ def make_connector_document(db_connector, db_user):
"source_markdown": "## Heading\n\nSome content.",
"unique_id": "test-id-001",
"document_type": DocumentType.CLICKUP_CONNECTOR,
- "search_space_id": db_connector.search_space_id,
+ "workspace_id": db_connector.workspace_id,
"connector_id": db_connector.id,
"created_by_id": str(db_user.id),
}
diff --git a/surfsense_backend/tests/integration/document_upload/conftest.py b/surfsense_backend/tests/integration/document_upload/conftest.py
index bd889360f..894ec1a7a 100644
--- a/surfsense_backend/tests/integration/document_upload/conftest.py
+++ b/surfsense_backend/tests/integration/document_upload/conftest.py
@@ -34,7 +34,7 @@ from tests.utils.helpers import (
auth_headers,
delete_document,
get_auth_token,
- get_search_space_id,
+ get_workspace_id,
)
limiter.enabled = False
@@ -66,7 +66,7 @@ class InlineTaskDispatcher:
document_id: int,
temp_path: str,
filename: str,
- search_space_id: int,
+ workspace_id: int,
user_id: str,
use_vision_llm: bool = False,
processing_mode: str = "basic",
@@ -80,7 +80,7 @@ class InlineTaskDispatcher:
document_id,
temp_path,
filename,
- search_space_id,
+ workspace_id,
user_id,
use_vision_llm=use_vision_llm,
processing_mode=processing_mode,
@@ -107,7 +107,7 @@ async def _ensure_tables():
# ---------------------------------------------------------------------------
-# Auth & search space (session-scoped, via the in-process app)
+# Auth & workspace (session-scoped, via the in-process app)
# ---------------------------------------------------------------------------
@@ -121,12 +121,12 @@ async def auth_token(_ensure_tables) -> str:
@pytest.fixture(scope="session")
-async def search_space_id(auth_token: str) -> int:
- """Discover the first search space belonging to the test user."""
+async def workspace_id(auth_token: str) -> int:
+ """Discover the first workspace belonging to the test user."""
async with httpx.AsyncClient(
transport=ASGITransport(app=app), base_url="http://test", timeout=30.0
) as c:
- return await get_search_space_id(c, auth_token)
+ return await get_workspace_id(c, auth_token)
@pytest.fixture(scope="session")
@@ -155,19 +155,19 @@ def cleanup_doc_ids() -> list[int]:
@pytest.fixture(scope="session", autouse=True)
-async def _purge_test_search_space(search_space_id: int):
+async def _purge_test_workspace(workspace_id: int):
"""Delete stale documents from previous runs before the session starts."""
conn = await asyncpg.connect(_ASYNCPG_URL)
try:
result = await conn.execute(
- "DELETE FROM documents WHERE search_space_id = $1",
- search_space_id,
+ "DELETE FROM documents WHERE workspace_id = $1",
+ workspace_id,
)
deleted = int(result.split()[-1])
if deleted:
print(
f"\n[purge] Deleted {deleted} stale document(s) "
- f"from search space {search_space_id}"
+ f"from workspace {workspace_id}"
)
finally:
await conn.close()
diff --git a/surfsense_backend/tests/integration/document_upload/test_document_upload.py b/surfsense_backend/tests/integration/document_upload/test_document_upload.py
index 13ceae828..2e8ff005b 100644
--- a/surfsense_backend/tests/integration/document_upload/test_document_upload.py
+++ b/surfsense_backend/tests/integration/document_upload/test_document_upload.py
@@ -37,11 +37,11 @@ class TestTxtFileUpload:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
resp = await upload_file(
- client, headers, "sample.txt", search_space_id=search_space_id
+ client, headers, "sample.txt", workspace_id=workspace_id
)
assert resp.status_code == 200
@@ -54,18 +54,18 @@ class TestTxtFileUpload:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
resp = await upload_file(
- client, headers, "sample.txt", search_space_id=search_space_id
+ client, headers, "sample.txt", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids)
statuses = await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id
+ client, headers, doc_ids, workspace_id=workspace_id
)
for did in doc_ids:
assert statuses[did]["status"]["state"] == "ready"
@@ -78,18 +78,18 @@ class TestPdfFileUpload:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
resp = await upload_file(
- client, headers, "sample.pdf", search_space_id=search_space_id
+ client, headers, "sample.pdf", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids)
statuses = await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
+ client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
)
for did in doc_ids:
assert statuses[did]["status"]["state"] == "ready"
@@ -107,14 +107,14 @@ class TestMultiFileUpload:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
resp = await upload_multiple_files(
client,
headers,
["sample.txt", "sample.md"],
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
assert resp.status_code == 200
@@ -139,22 +139,22 @@ class TestDuplicateFileUpload:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
resp1 = await upload_file(
- client, headers, "sample.txt", search_space_id=search_space_id
+ client, headers, "sample.txt", workspace_id=workspace_id
)
assert resp1.status_code == 200
first_ids = resp1.json()["document_ids"]
cleanup_doc_ids.extend(first_ids)
await poll_document_status(
- client, headers, first_ids, search_space_id=search_space_id
+ client, headers, first_ids, workspace_id=workspace_id
)
resp2 = await upload_file(
- client, headers, "sample.txt", search_space_id=search_space_id
+ client, headers, "sample.txt", workspace_id=workspace_id
)
assert resp2.status_code == 200
@@ -179,18 +179,18 @@ class TestDuplicateContentDetection:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
tmp_path: Path,
):
resp1 = await upload_file(
- client, headers, "sample.txt", search_space_id=search_space_id
+ client, headers, "sample.txt", workspace_id=workspace_id
)
assert resp1.status_code == 200
first_ids = resp1.json()["document_ids"]
cleanup_doc_ids.extend(first_ids)
await poll_document_status(
- client, headers, first_ids, search_space_id=search_space_id
+ client, headers, first_ids, workspace_id=workspace_id
)
src = FIXTURES_DIR / "sample.txt"
@@ -202,7 +202,7 @@ class TestDuplicateContentDetection:
"/api/v1/documents/fileupload",
headers=headers,
files={"files": ("renamed_sample.txt", f)},
- data={"search_space_id": str(search_space_id)},
+ data={"workspace_id": str(workspace_id)},
)
assert resp2.status_code == 200
second_ids = resp2.json()["document_ids"]
@@ -212,7 +212,7 @@ class TestDuplicateContentDetection:
)
statuses = await poll_document_status(
- client, headers, second_ids, search_space_id=search_space_id
+ client, headers, second_ids, workspace_id=workspace_id
)
for did in second_ids:
assert statuses[did]["status"]["state"] == "failed"
@@ -231,11 +231,11 @@ class TestEmptyFileUpload:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
resp = await upload_file(
- client, headers, "empty.pdf", search_space_id=search_space_id
+ client, headers, "empty.pdf", workspace_id=workspace_id
)
assert resp.status_code == 200
@@ -244,7 +244,7 @@ class TestEmptyFileUpload:
assert doc_ids, "Expected at least one document id for empty PDF upload"
statuses = await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id, timeout=120.0
+ client, headers, doc_ids, workspace_id=workspace_id, timeout=120.0
)
for did in doc_ids:
assert statuses[did]["status"]["state"] == "failed"
@@ -264,14 +264,14 @@ class TestUnauthenticatedUpload:
async def test_upload_without_auth_returns_401(
self,
client: httpx.AsyncClient,
- search_space_id: int,
+ workspace_id: int,
):
file_path = FIXTURES_DIR / "sample.txt"
with open(file_path, "rb") as f:
resp = await client.post(
"/api/v1/documents/fileupload",
files={"files": ("sample.txt", f)},
- data={"search_space_id": str(search_space_id)},
+ data={"workspace_id": str(workspace_id)},
)
assert resp.status_code == 401
@@ -288,12 +288,12 @@ class TestNoFilesUpload:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
):
resp = await client.post(
"/api/v1/documents/fileupload",
headers=headers,
- data={"search_space_id": str(search_space_id)},
+ data={"workspace_id": str(workspace_id)},
)
assert resp.status_code in {400, 422}
@@ -310,24 +310,22 @@ class TestDocumentSearchability:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
resp = await upload_file(
- client, headers, "sample.txt", search_space_id=search_space_id
+ client, headers, "sample.txt", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids)
- await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id
- )
+ await poll_document_status(client, headers, doc_ids, workspace_id=workspace_id)
search_resp = await client.get(
"/api/v1/documents/search",
headers=headers,
- params={"title": "sample", "search_space_id": search_space_id},
+ params={"title": "sample", "workspace_id": workspace_id},
)
assert search_resp.status_code == 200
diff --git a/surfsense_backend/tests/integration/document_upload/test_etl_credits.py b/surfsense_backend/tests/integration/document_upload/test_etl_credits.py
index 6a2972598..ec31c6d6c 100644
--- a/surfsense_backend/tests/integration/document_upload/test_etl_credits.py
+++ b/surfsense_backend/tests/integration/document_upload/test_etl_credits.py
@@ -57,7 +57,7 @@ class TestBalanceDecrementsOnSuccess:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
credits,
):
@@ -65,14 +65,14 @@ class TestBalanceDecrementsOnSuccess:
before = await _get_balance(client, headers)
resp = await upload_file(
- client, headers, "sample.pdf", search_space_id=search_space_id
+ client, headers, "sample.pdf", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids)
statuses = await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
+ client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
)
for did in doc_ids:
assert statuses[did]["status"]["state"] == "ready"
@@ -94,21 +94,21 @@ class TestUploadRejectedWhenCreditExhausted:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
credits,
):
await credits.set(balance_micros=0)
resp = await upload_file(
- client, headers, "sample.pdf", search_space_id=search_space_id
+ client, headers, "sample.pdf", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids)
statuses = await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
+ client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
)
for did in doc_ids:
assert statuses[did]["status"]["state"] == "failed"
@@ -121,21 +121,21 @@ class TestUploadRejectedWhenCreditExhausted:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
credits,
):
await credits.set(balance_micros=0)
resp = await upload_file(
- client, headers, "sample.pdf", search_space_id=search_space_id
+ client, headers, "sample.pdf", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids)
await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
+ client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
)
balance = await _get_balance(client, headers)
@@ -157,28 +157,28 @@ class TestInsufficientCreditsNotification:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
credits,
):
await credits.set(balance_micros=0)
resp = await upload_file(
- client, headers, "sample.pdf", search_space_id=search_space_id
+ client, headers, "sample.pdf", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids)
await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
+ client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
)
notifications = await get_notifications(
client,
headers,
type_filter="insufficient_credits",
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
assert len(notifications) >= 1, (
"Expected at least one insufficient_credits notification"
@@ -206,28 +206,26 @@ class TestDocumentProcessingNotification:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
credits,
):
await credits.set(balance_micros=credits.pages(1000))
resp = await upload_file(
- client, headers, "sample.txt", search_space_id=search_space_id
+ client, headers, "sample.txt", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids)
- await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id
- )
+ await poll_document_status(client, headers, doc_ids, workspace_id=workspace_id)
notifications = await get_notifications(
client,
headers,
type_filter="document_processing",
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
)
completed = [
n
@@ -252,7 +250,7 @@ class TestBalanceUnchangedOnProcessingFailure:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
credits,
):
@@ -260,7 +258,7 @@ class TestBalanceUnchangedOnProcessingFailure:
await credits.set(balance_micros=starting)
resp = await upload_file(
- client, headers, "empty.pdf", search_space_id=search_space_id
+ client, headers, "empty.pdf", workspace_id=workspace_id
)
assert resp.status_code == 200
doc_ids = resp.json()["document_ids"]
@@ -268,7 +266,7 @@ class TestBalanceUnchangedOnProcessingFailure:
if doc_ids:
statuses = await poll_document_status(
- client, headers, doc_ids, search_space_id=search_space_id, timeout=120.0
+ client, headers, doc_ids, workspace_id=workspace_id, timeout=120.0
)
for did in doc_ids:
assert statuses[did]["status"]["state"] == "failed"
@@ -292,7 +290,7 @@ class TestSecondUploadExceedsCredit:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
credits,
):
@@ -301,14 +299,14 @@ class TestSecondUploadExceedsCredit:
await credits.set(balance_micros=credits.pages(1))
resp1 = await upload_file(
- client, headers, "sample.pdf", search_space_id=search_space_id
+ client, headers, "sample.pdf", workspace_id=workspace_id
)
assert resp1.status_code == 200
first_ids = resp1.json()["document_ids"]
cleanup_doc_ids.extend(first_ids)
statuses1 = await poll_document_status(
- client, headers, first_ids, search_space_id=search_space_id, timeout=300.0
+ client, headers, first_ids, workspace_id=workspace_id, timeout=300.0
)
for did in first_ids:
assert statuses1[did]["status"]["state"] == "ready"
@@ -317,7 +315,7 @@ class TestSecondUploadExceedsCredit:
client,
headers,
"sample.pdf",
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
filename_override="sample_copy.pdf",
)
assert resp2.status_code == 200
@@ -325,7 +323,7 @@ class TestSecondUploadExceedsCredit:
cleanup_doc_ids.extend(second_ids)
statuses2 = await poll_document_status(
- client, headers, second_ids, search_space_id=search_space_id, timeout=300.0
+ client, headers, second_ids, workspace_id=workspace_id, timeout=300.0
)
for did in second_ids:
assert statuses2[did]["status"]["state"] == "failed"
diff --git a/surfsense_backend/tests/integration/document_upload/test_stripe_credit_purchases.py b/surfsense_backend/tests/integration/document_upload/test_stripe_credit_purchases.py
index dcd4d1d2f..e53b37904 100644
--- a/surfsense_backend/tests/integration/document_upload/test_stripe_credit_purchases.py
+++ b/surfsense_backend/tests/integration/document_upload/test_stripe_credit_purchases.py
@@ -187,7 +187,7 @@ class TestStripeCheckoutSessionCreation:
self,
client,
headers,
- search_space_id: int,
+ workspace_id: int,
monkeypatch,
):
checkout_session = SimpleNamespace(
@@ -205,7 +205,7 @@ class TestStripeCheckoutSessionCreation:
response = await client.post(
"/api/v1/stripe/create-credit-checkout-session",
headers=headers,
- json={"quantity": 2, "search_space_id": search_space_id},
+ json={"quantity": 2, "workspace_id": workspace_id},
)
assert response.status_code == 200, response.text
@@ -217,12 +217,12 @@ class TestStripeCheckoutSessionCreation:
]
assert (
fake_client.last_params["success_url"]
- == f"http://localhost:3000/dashboard/{search_space_id}/purchase-success"
+ == f"http://localhost:3000/dashboard/{workspace_id}/purchase-success"
"?session_id={CHECKOUT_SESSION_ID}"
)
assert (
fake_client.last_params["cancel_url"]
- == f"http://localhost:3000/dashboard/{search_space_id}/purchase-cancel"
+ == f"http://localhost:3000/dashboard/{workspace_id}/purchase-cancel"
)
assert fake_client.last_params["metadata"]["purchase_type"] == "credits"
@@ -244,7 +244,7 @@ class TestStripeCheckoutSessionCreation:
self,
client,
headers,
- search_space_id: int,
+ workspace_id: int,
monkeypatch,
):
monkeypatch.setattr(stripe_routes.config, "STRIPE_CREDIT_BUYING_ENABLED", False)
@@ -252,7 +252,7 @@ class TestStripeCheckoutSessionCreation:
response = await client.post(
"/api/v1/stripe/create-credit-checkout-session",
headers=headers,
- json={"quantity": 2, "search_space_id": search_space_id},
+ json={"quantity": 2, "workspace_id": workspace_id},
)
assert response.status_code == 503, response.text
@@ -270,7 +270,7 @@ class TestStripeWebhookFulfillment:
self,
client,
headers,
- search_space_id: int,
+ workspace_id: int,
credits,
monkeypatch,
):
@@ -291,7 +291,7 @@ class TestStripeWebhookFulfillment:
create_response = await client.post(
"/api/v1/stripe/create-credit-checkout-session",
headers=headers,
- json={"quantity": 3, "search_space_id": search_space_id},
+ json={"quantity": 3, "workspace_id": workspace_id},
)
assert create_response.status_code == 200, create_response.text
@@ -359,7 +359,7 @@ class TestStripeReconciliation:
self,
client,
headers,
- search_space_id: int,
+ workspace_id: int,
credits,
monkeypatch,
):
@@ -380,7 +380,7 @@ class TestStripeReconciliation:
create_response = await client.post(
"/api/v1/stripe/create-credit-checkout-session",
headers=headers,
- json={"quantity": 3, "search_space_id": search_space_id},
+ json={"quantity": 3, "workspace_id": workspace_id},
)
assert create_response.status_code == 200, create_response.text
assert await _get_balance(TEST_EMAIL) == 1_000_000
@@ -433,7 +433,7 @@ class TestStripeReconciliation:
self,
client,
headers,
- search_space_id: int,
+ workspace_id: int,
credits,
monkeypatch,
):
@@ -454,7 +454,7 @@ class TestStripeReconciliation:
create_response = await client.post(
"/api/v1/stripe/create-credit-checkout-session",
headers=headers,
- json={"quantity": 1, "search_space_id": search_space_id},
+ json={"quantity": 1, "workspace_id": workspace_id},
)
assert create_response.status_code == 200, create_response.text
diff --git a/surfsense_backend/tests/integration/document_upload/test_upload_limits.py b/surfsense_backend/tests/integration/document_upload/test_upload_limits.py
index a56398baa..027d8a7d0 100644
--- a/surfsense_backend/tests/integration/document_upload/test_upload_limits.py
+++ b/surfsense_backend/tests/integration/document_upload/test_upload_limits.py
@@ -34,14 +34,14 @@ class TestPerFileSizeLimit:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
):
oversized = io.BytesIO(b"\x00" * (500 * 1024 * 1024 + 1))
resp = await client.post(
"/api/v1/documents/fileupload",
headers=headers,
files=[("files", ("big.pdf", oversized, "application/pdf"))],
- data={"search_space_id": str(search_space_id)},
+ data={"workspace_id": str(workspace_id)},
)
assert resp.status_code == 413
assert "per-file limit" in resp.json()["detail"].lower()
@@ -50,7 +50,7 @@ class TestPerFileSizeLimit:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
at_limit = io.BytesIO(b"\x00" * (500 * 1024 * 1024))
@@ -58,7 +58,7 @@ class TestPerFileSizeLimit:
"/api/v1/documents/fileupload",
headers=headers,
files=[("files", ("exact500mb.txt", at_limit, "text/plain"))],
- data={"search_space_id": str(search_space_id)},
+ data={"workspace_id": str(workspace_id)},
)
assert resp.status_code == 200
cleanup_doc_ids.extend(resp.json().get("document_ids", []))
@@ -76,7 +76,7 @@ class TestNoFileCountLimit:
self,
client: httpx.AsyncClient,
headers: dict[str, str],
- search_space_id: int,
+ workspace_id: int,
cleanup_doc_ids: list[int],
):
files = [
@@ -87,7 +87,7 @@ class TestNoFileCountLimit:
"/api/v1/documents/fileupload",
headers=headers,
files=files,
- data={"search_space_id": str(search_space_id)},
+ data={"workspace_id": str(workspace_id)},
)
assert resp.status_code == 200
cleanup_doc_ids.extend(resp.json().get("document_ids", []))
diff --git a/surfsense_backend/tests/integration/google_unification/conftest.py b/surfsense_backend/tests/integration/google_unification/conftest.py
index 151ee98e3..861f171f0 100644
--- a/surfsense_backend/tests/integration/google_unification/conftest.py
+++ b/surfsense_backend/tests/integration/google_unification/conftest.py
@@ -18,8 +18,8 @@ from app.db import (
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
- SearchSpace,
User,
+ Workspace,
)
EMBEDDING_DIM = app_config.embedding_model_instance.dimension
@@ -31,7 +31,7 @@ def make_document(
title: str,
document_type: DocumentType,
content: str,
- search_space_id: int,
+ workspace_id: int,
created_by_id: str,
) -> Document:
"""Build a Document instance with unique hashes and a dummy embedding."""
@@ -43,7 +43,7 @@ def make_document(
content_hash=f"content-{uid}",
unique_identifier_hash=f"uid-{uid}",
source_markdown=content,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=created_by_id,
embedding=DUMMY_EMBEDDING,
updated_at=datetime.now(UTC),
@@ -66,35 +66,35 @@ def make_chunk(*, content: str, document_id: int) -> Chunk:
@pytest_asyncio.fixture
async def seed_google_docs(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Insert a native Drive doc, a legacy Composio Drive doc, and a FILE doc.
Returns a dict with keys ``native_doc``, ``legacy_doc``, ``file_doc``,
- plus ``search_space`` and ``user``.
+ plus ``workspace`` and ``user``.
"""
user_id = str(db_user.id)
- space_id = db_search_space.id
+ space_id = db_workspace.id
native_doc = make_document(
title="Native Drive Document",
document_type=DocumentType.GOOGLE_DRIVE_FILE,
content="quarterly report from native google drive connector",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
)
legacy_doc = make_document(
title="Legacy Composio Drive Document",
document_type=DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
content="quarterly report from composio google drive connector",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
)
file_doc = make_document(
title="Uploaded PDF",
document_type=DocumentType.FILE,
content="unrelated uploaded file about quarterly reports",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
)
@@ -121,7 +121,7 @@ async def seed_google_docs(
"native_doc": native_doc,
"legacy_doc": legacy_doc,
"file_doc": file_doc,
- "search_space": db_search_space,
+ "workspace": db_workspace,
"user": db_user,
}
@@ -136,8 +136,8 @@ async def seed_google_docs(
async def committed_google_data(async_engine):
"""Insert native, legacy, and FILE docs via a committed transaction.
- Yields ``{"search_space_id": int, "user_id": str}``.
- Cleans up by deleting the search space (cascades to documents / chunks).
+ Yields ``{"workspace_id": int, "user_id": str}``.
+ Cleans up by deleting the workspace (cascades to documents / chunks).
"""
space_id = None
@@ -155,7 +155,7 @@ async def committed_google_data(async_engine):
session.add(user)
await session.flush()
- space = SearchSpace(name=f"Google Test {uuid.uuid4().hex[:6]}", user_id=user.id)
+ space = Workspace(name=f"Google Test {uuid.uuid4().hex[:6]}", user_id=user.id)
session.add(space)
await session.flush()
space_id = space.id
@@ -165,21 +165,21 @@ async def committed_google_data(async_engine):
title="Native Drive Doc",
document_type=DocumentType.GOOGLE_DRIVE_FILE,
content="quarterly budget from native google drive",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
)
legacy_doc = make_document(
title="Legacy Composio Drive Doc",
document_type=DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
content="quarterly budget from composio google drive",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
)
file_doc = make_document(
title="Plain File",
document_type=DocumentType.FILE,
content="quarterly budget uploaded as file",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
)
session.add_all([native_doc, legacy_doc, file_doc])
@@ -195,11 +195,11 @@ async def committed_google_data(async_engine):
)
await session.flush()
- yield {"search_space_id": space_id, "user_id": user_id}
+ yield {"workspace_id": space_id, "user_id": user_id}
async with async_engine.begin() as conn:
await conn.execute(
- text("DELETE FROM searchspaces WHERE id = :sid"), {"sid": space_id}
+ text("DELETE FROM workspaces WHERE id = :sid"), {"sid": space_id}
)
@@ -257,7 +257,7 @@ async def seed_connector(
):
"""Seed a connector with committed data. Returns dict and cleanup function.
- Yields ``{"connector_id", "search_space_id", "user_id"}``.
+ Yields ``{"connector_id", "workspace_id", "user_id"}``.
"""
space_id = None
@@ -275,9 +275,7 @@ async def seed_connector(
session.add(user)
await session.flush()
- space = SearchSpace(
- name=f"{name_prefix} {uuid.uuid4().hex[:6]}", user_id=user.id
- )
+ space = Workspace(name=f"{name_prefix} {uuid.uuid4().hex[:6]}", user_id=user.id)
session.add(space)
await session.flush()
space_id = space.id
@@ -287,7 +285,7 @@ async def seed_connector(
connector_type=connector_type,
is_indexable=True,
config=config,
- search_space_id=space_id,
+ workspace_id=space_id,
user_id=user.id,
)
session.add(connector)
@@ -297,14 +295,14 @@ async def seed_connector(
return {
"connector_id": connector_id,
- "search_space_id": space_id,
+ "workspace_id": space_id,
"user_id": user_id,
}
async def cleanup_space(async_engine, space_id: int):
- """Delete a search space (cascades to connectors/documents)."""
+ """Delete a workspace (cascades to connectors/documents)."""
async with async_engine.begin() as conn:
await conn.execute(
- text("DELETE FROM searchspaces WHERE id = :sid"), {"sid": space_id}
+ text("DELETE FROM workspaces WHERE id = :sid"), {"sid": space_id}
)
diff --git a/surfsense_backend/tests/integration/google_unification/test_calendar_indexer_credentials.py b/surfsense_backend/tests/integration/google_unification/test_calendar_indexer_credentials.py
index 44ff5c48a..2b674c336 100644
--- a/surfsense_backend/tests/integration/google_unification/test_calendar_indexer_credentials.py
+++ b/surfsense_backend/tests/integration/google_unification/test_calendar_indexer_credentials.py
@@ -37,7 +37,7 @@ async def composio_calendar(async_engine):
name_prefix="cal-composio",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture
@@ -49,7 +49,7 @@ async def composio_calendar_no_id(async_engine):
name_prefix="cal-noid",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture
@@ -67,7 +67,7 @@ async def native_calendar(async_engine):
name_prefix="cal-native",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@patch(_GET_ACCESS_TOKEN)
@@ -98,7 +98,7 @@ async def test_composio_calendar_uses_composio_service(
await index_google_calendar_events(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
)
@@ -137,7 +137,7 @@ async def test_composio_calendar_without_account_id_returns_error(
count, _skipped, error = await index_google_calendar_events(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
)
@@ -179,7 +179,7 @@ async def test_native_calendar_uses_google_calendar_connector(
await index_google_calendar_events(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
)
diff --git a/surfsense_backend/tests/integration/google_unification/test_drive_indexer_credentials.py b/surfsense_backend/tests/integration/google_unification/test_drive_indexer_credentials.py
index 810c3ceab..9795628ed 100644
--- a/surfsense_backend/tests/integration/google_unification/test_drive_indexer_credentials.py
+++ b/surfsense_backend/tests/integration/google_unification/test_drive_indexer_credentials.py
@@ -62,7 +62,7 @@ async def committed_drive_connector(async_engine):
name_prefix="drive-composio",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture
@@ -80,7 +80,7 @@ async def committed_native_drive_connector(async_engine):
name_prefix="drive-native",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture
@@ -92,7 +92,7 @@ async def committed_composio_no_account_id(async_engine):
name_prefix="drive-noid",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@patch(_GET_ACCESS_TOKEN)
@@ -127,7 +127,7 @@ async def test_composio_drive_indexer_uses_composio_drive_client(
await index_google_drive_files(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
folder_id="test-folder-id",
)
@@ -167,7 +167,7 @@ async def test_composio_connector_without_account_id_returns_error(
count, _skipped, error, _unsupported = await index_google_drive_files(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
folder_id="test-folder-id",
)
@@ -207,7 +207,7 @@ async def test_native_connector_uses_google_drive_client(
await index_google_drive_files(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
folder_id="test-folder-id",
)
diff --git a/surfsense_backend/tests/integration/google_unification/test_gmail_indexer_credentials.py b/surfsense_backend/tests/integration/google_unification/test_gmail_indexer_credentials.py
index b869f5607..88cdbb439 100644
--- a/surfsense_backend/tests/integration/google_unification/test_gmail_indexer_credentials.py
+++ b/surfsense_backend/tests/integration/google_unification/test_gmail_indexer_credentials.py
@@ -37,7 +37,7 @@ async def composio_gmail(async_engine):
name_prefix="gmail-composio",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture
@@ -49,7 +49,7 @@ async def composio_gmail_no_id(async_engine):
name_prefix="gmail-noid",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture
@@ -67,7 +67,7 @@ async def native_gmail(async_engine):
name_prefix="gmail-native",
)
yield data
- await cleanup_space(async_engine, data["search_space_id"])
+ await cleanup_space(async_engine, data["workspace_id"])
@patch(_GET_ACCESS_TOKEN)
@@ -101,7 +101,7 @@ async def test_composio_gmail_uses_composio_service(
await index_google_gmail_messages(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
)
@@ -140,7 +140,7 @@ async def test_composio_gmail_without_account_id_returns_error(
count, _skipped, error = await index_google_gmail_messages(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
)
@@ -180,7 +180,7 @@ async def test_native_gmail_uses_google_gmail_connector(
await index_google_gmail_messages(
session=session,
connector_id=data["connector_id"],
- search_space_id=data["search_space_id"],
+ workspace_id=data["workspace_id"],
user_id=data["user_id"],
)
diff --git a/surfsense_backend/tests/integration/google_unification/test_hybrid_search_type_filtering.py b/surfsense_backend/tests/integration/google_unification/test_hybrid_search_type_filtering.py
index 402d3ee0b..8230fb16b 100644
--- a/surfsense_backend/tests/integration/google_unification/test_hybrid_search_type_filtering.py
+++ b/surfsense_backend/tests/integration/google_unification/test_hybrid_search_type_filtering.py
@@ -19,13 +19,13 @@ async def test_list_of_types_returns_both_matching_doc_types(
db_session, seed_google_docs
):
"""Searching with a list of document types returns documents of ALL listed types."""
- space_id = seed_google_docs["search_space"].id
+ space_id = seed_google_docs["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly report",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
document_type=["GOOGLE_DRIVE_FILE", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"],
query_embedding=DUMMY_EMBEDDING,
)
@@ -40,13 +40,13 @@ async def test_list_of_types_returns_both_matching_doc_types(
async def test_single_string_type_returns_only_that_type(db_session, seed_google_docs):
"""Searching with a single string type returns only documents of that exact type."""
- space_id = seed_google_docs["search_space"].id
+ space_id = seed_google_docs["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly report",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
document_type="GOOGLE_DRIVE_FILE",
query_embedding=DUMMY_EMBEDDING,
)
@@ -59,13 +59,13 @@ async def test_single_string_type_returns_only_that_type(db_session, seed_google
async def test_all_invalid_types_returns_empty(db_session, seed_google_docs):
"""Searching with a list of nonexistent types returns an empty list, no exceptions."""
- space_id = seed_google_docs["search_space"].id
+ space_id = seed_google_docs["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly report",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
document_type=["NONEXISTENT_TYPE"],
query_embedding=DUMMY_EMBEDDING,
)
diff --git a/surfsense_backend/tests/integration/google_unification/test_search_includes_legacy_docs.py b/surfsense_backend/tests/integration/google_unification/test_search_includes_legacy_docs.py
index d9b089c12..e720f43ba 100644
--- a/surfsense_backend/tests/integration/google_unification/test_search_includes_legacy_docs.py
+++ b/surfsense_backend/tests/integration/google_unification/test_search_includes_legacy_docs.py
@@ -19,13 +19,13 @@ async def test_search_google_drive_includes_legacy_composio_docs(
async_engine, committed_google_data, patched_session_factory, patched_embed
):
"""search_google_drive returns both GOOGLE_DRIVE_FILE and COMPOSIO_GOOGLE_DRIVE_CONNECTOR docs."""
- space_id = committed_google_data["search_space_id"]
+ space_id = committed_google_data["workspace_id"]
async with patched_session_factory() as session:
- service = ConnectorService(session, search_space_id=space_id)
+ service = ConnectorService(session, workspace_id=space_id)
_, raw_docs = await service.search_google_drive(
user_query="quarterly budget",
- search_space_id=space_id,
+ workspace_id=space_id,
top_k=10,
)
@@ -51,13 +51,13 @@ async def test_search_files_does_not_include_google_types(
async_engine, committed_google_data, patched_session_factory, patched_embed
):
"""search_files returns only FILE docs, not Google Drive docs."""
- space_id = committed_google_data["search_space_id"]
+ space_id = committed_google_data["workspace_id"]
async with patched_session_factory() as session:
- service = ConnectorService(session, search_space_id=space_id)
+ service = ConnectorService(session, workspace_id=space_id)
_, raw_docs = await service.search_files(
user_query="quarterly budget",
- search_space_id=space_id,
+ workspace_id=space_id,
top_k=10,
)
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/adapters/test_file_upload_adapter.py b/surfsense_backend/tests/integration/indexing_pipeline/adapters/test_file_upload_adapter.py
index 814129c8d..9b5c25504 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/adapters/test_file_upload_adapter.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/adapters/test_file_upload_adapter.py
@@ -8,19 +8,19 @@ pytestmark = pytest.mark.integration
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
-async def test_sets_status_ready(db_session, db_search_space, db_user, mocker):
+async def test_sets_status_ready(db_session, db_workspace, db_user, mocker):
"""Document status is READY after successful indexing."""
adapter = UploadDocumentAdapter(db_session)
await adapter.index(
markdown_content="## Hello\n\nSome content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
@@ -28,19 +28,19 @@ async def test_sets_status_ready(db_session, db_search_space, db_user, mocker):
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
-async def test_content_is_source_markdown(db_session, db_search_space, db_user, mocker):
+async def test_content_is_source_markdown(db_session, db_workspace, db_user, mocker):
"""Document content is set to the extracted source markdown."""
adapter = UploadDocumentAdapter(db_session)
await adapter.index(
markdown_content="## Hello\n\nSome content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
@@ -48,19 +48,19 @@ async def test_content_is_source_markdown(db_session, db_search_space, db_user,
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
-async def test_chunks_written_to_db(db_session, db_search_space, db_user, mocker):
+async def test_chunks_written_to_db(db_session, db_workspace, db_user, mocker):
"""Chunks derived from the source markdown are persisted in the DB."""
adapter = UploadDocumentAdapter(db_session)
await adapter.index(
markdown_content="## Hello\n\nSome content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
@@ -74,7 +74,7 @@ async def test_chunks_written_to_db(db_session, db_search_space, db_user, mocker
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
-async def test_raises_on_indexing_failure(db_session, db_search_space, db_user, mocker):
+async def test_raises_on_indexing_failure(db_session, db_workspace, db_user, mocker):
"""RuntimeError is raised when the indexing step fails so the caller can fire a failure notification."""
adapter = UploadDocumentAdapter(db_session)
with pytest.raises(RuntimeError, match=r"Embedding failed|Indexing failed"):
@@ -82,7 +82,7 @@ async def test_raises_on_indexing_failure(db_session, db_search_space, db_user,
markdown_content="## Hello\n\nSome content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
@@ -93,19 +93,19 @@ async def test_raises_on_indexing_failure(db_session, db_search_space, db_user,
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
-async def test_reindex_updates_content(db_session, db_search_space, db_user, mocker):
+async def test_reindex_updates_content(db_session, db_workspace, db_user, mocker):
"""Document content is updated to the new source markdown after reindexing."""
adapter = UploadDocumentAdapter(db_session)
await adapter.index(
markdown_content="## Original\n\nOriginal content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
@@ -119,21 +119,19 @@ async def test_reindex_updates_content(db_session, db_search_space, db_user, moc
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
-async def test_reindex_updates_content_hash(
- db_session, db_search_space, db_user, mocker
-):
+async def test_reindex_updates_content_hash(db_session, db_workspace, db_user, mocker):
"""Content hash is recomputed after reindexing with new source markdown."""
adapter = UploadDocumentAdapter(db_session)
await adapter.index(
markdown_content="## Original\n\nOriginal content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
original_hash = document.content_hash
@@ -148,19 +146,19 @@ async def test_reindex_updates_content_hash(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
-async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, mocker):
+async def test_reindex_sets_status_ready(db_session, db_workspace, db_user, mocker):
"""Document status is READY after successful reindexing."""
adapter = UploadDocumentAdapter(db_session)
await adapter.index(
markdown_content="## Original\n\nOriginal content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
@@ -174,7 +172,7 @@ async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, m
@pytest.mark.usefixtures("patched_embed_texts")
-async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker):
+async def test_reindex_replaces_chunks(db_session, db_workspace, db_user, mocker):
"""Reindexing replaces old chunks with new content rather than appending."""
mocker.patch(
"app.indexing_pipeline.cache.cached_indexing.chunk_text_hybrid",
@@ -186,12 +184,12 @@ async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, moc
markdown_content="## Original\n\nOriginal content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
document_id = document.id
@@ -212,7 +210,7 @@ async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, moc
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_reindex_clears_reindexing_flag(
- db_session, db_search_space, db_user, mocker
+ db_session, db_workspace, db_user, mocker
):
"""After successful reindex, content_needs_reindexing is False."""
adapter = UploadDocumentAdapter(db_session)
@@ -220,12 +218,12 @@ async def test_reindex_clears_reindexing_flag(
markdown_content="## Original\n\nOriginal content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
@@ -241,7 +239,7 @@ async def test_reindex_clears_reindexing_flag(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_reindex_raises_on_failure(
- db_session, db_search_space, db_user, patched_embed_texts, mocker
+ db_session, db_workspace, db_user, patched_embed_texts, mocker
):
"""RuntimeError is raised when reindexing fails so the caller can handle it."""
@@ -250,12 +248,12 @@ async def test_reindex_raises_on_failure(
markdown_content="## Original\n\nOriginal content.",
filename="test.pdf",
etl_service="UNSTRUCTURED",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
document = result.scalars().first()
@@ -269,7 +267,7 @@ async def test_reindex_raises_on_failure(
async def test_reindex_raises_on_empty_source_markdown(
- db_session, db_search_space, db_user, mocker
+ db_session, db_workspace, db_user, mocker
):
"""Reindexing a document with no source_markdown raises immediately."""
from app.db import DocumentType
@@ -281,7 +279,7 @@ async def test_reindex_raises_on_empty_source_markdown(
content_hash="abc123",
unique_identifier_hash="def456",
source_markdown="",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
created_by_id=str(db_user.id),
)
db_session.add(document)
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_calendar_pipeline.py b/surfsense_backend/tests/integration/indexing_pipeline/test_calendar_pipeline.py
index 8e1ed3752..6147fb9e7 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_calendar_pipeline.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_calendar_pipeline.py
@@ -15,14 +15,14 @@ pytestmark = pytest.mark.integration
def _cal_doc(
- *, unique_id: str, search_space_id: int, connector_id: int, user_id: str
+ *, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
return ConnectorDocument(
title=f"Event {unique_id}",
source_markdown=f"## Calendar Event\n\nDetails for {unique_id}",
unique_id=unique_id,
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -36,13 +36,13 @@ def _cal_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_calendar_pipeline_creates_ready_document(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""A Calendar ConnectorDocument flows through prepare + index to a READY document."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
doc = _cal_doc(
unique_id="evt-1",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@@ -54,7 +54,7 @@ async def test_calendar_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == space_id)
+ select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@@ -65,10 +65,10 @@ async def test_calendar_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_calendar_legacy_doc_migrated(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""A legacy Composio Calendar doc is migrated and reused."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
user_id = str(db_user.id)
evt_id = "evt-legacy-cal"
@@ -82,7 +82,7 @@ async def test_calendar_legacy_doc_migrated(
content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash,
source_markdown="## Old event",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"},
@@ -93,7 +93,7 @@ async def test_calendar_legacy_doc_migrated(
connector_doc = _cal_doc(
unique_id=evt_id,
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_drive_pipeline.py b/surfsense_backend/tests/integration/indexing_pipeline/test_drive_pipeline.py
index 6e85421ea..a2bd7cd30 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_drive_pipeline.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_drive_pipeline.py
@@ -15,14 +15,14 @@ pytestmark = pytest.mark.integration
def _drive_doc(
- *, unique_id: str, search_space_id: int, connector_id: int, user_id: str
+ *, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
return ConnectorDocument(
title=f"File {unique_id}.pdf",
source_markdown=f"## Document Content\n\nText from file {unique_id}",
unique_id=unique_id,
document_type=DocumentType.GOOGLE_DRIVE_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -35,13 +35,13 @@ def _drive_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_drive_pipeline_creates_ready_document(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""A Drive ConnectorDocument flows through prepare + index to a READY document."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
doc = _drive_doc(
unique_id="file-abc",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@@ -53,7 +53,7 @@ async def test_drive_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == space_id)
+ select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@@ -64,10 +64,10 @@ async def test_drive_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_drive_legacy_doc_migrated(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""A legacy Composio Drive doc is migrated and reused."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
user_id = str(db_user.id)
file_id = "file-legacy-drive"
@@ -81,7 +81,7 @@ async def test_drive_legacy_doc_migrated(
content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash,
source_markdown="## Old file content",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"},
@@ -92,7 +92,7 @@ async def test_drive_legacy_doc_migrated(
connector_doc = _drive_doc(
unique_id=file_id,
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
@@ -114,7 +114,7 @@ async def test_drive_legacy_doc_migrated(
async def test_should_skip_file_skips_failed_document(
db_session,
- db_search_space,
+ db_workspace,
db_user,
):
"""A FAILED document with unchanged md5 must be skipped — user can manually retry via Quick Index."""
@@ -139,7 +139,7 @@ async def test_should_skip_file_skips_failed_document(
if stub:
sys.modules.pop(pkg, None)
- space_id = db_search_space.id
+ space_id = db_workspace.id
file_id = "file-failed-drive"
md5 = "abc123deadbeef"
@@ -153,7 +153,7 @@ async def test_should_skip_file_skips_failed_document(
content_hash=f"ch-{doc_hash[:12]}",
unique_identifier_hash=doc_hash,
source_markdown="## Real content",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=str(db_user.id),
embedding=[0.1] * _EMBEDDING_DIM,
status=DocumentStatus.failed("LLM rate limit exceeded"),
@@ -182,7 +182,7 @@ async def test_should_skip_file_skips_failed_document(
@pytest.mark.parametrize("stuck_state", ["pending", "processing"])
async def test_should_skip_file_retries_stuck_document(
db_session,
- db_search_space,
+ db_workspace,
db_user,
stuck_state,
):
@@ -208,7 +208,7 @@ async def test_should_skip_file_retries_stuck_document(
if stub:
sys.modules.pop(pkg, None)
- space_id = db_search_space.id
+ space_id = db_workspace.id
file_id = f"file-{stuck_state}-drive"
md5 = "stuck123checksum"
@@ -227,7 +227,7 @@ async def test_should_skip_file_retries_stuck_document(
content_hash=f"ch-{doc_hash[:12]}",
unique_identifier_hash=doc_hash,
source_markdown="",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=str(db_user.id),
status=status,
document_metadata={
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_dropbox_pipeline.py b/surfsense_backend/tests/integration/indexing_pipeline/test_dropbox_pipeline.py
index 9faa3db91..7e4849db0 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_dropbox_pipeline.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_dropbox_pipeline.py
@@ -14,14 +14,14 @@ pytestmark = pytest.mark.integration
def _dropbox_doc(
- *, unique_id: str, search_space_id: int, connector_id: int, user_id: str
+ *, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
return ConnectorDocument(
title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}",
unique_id=unique_id,
document_type=DocumentType.DROPBOX_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -34,13 +34,13 @@ def _dropbox_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_dropbox_pipeline_creates_ready_document(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""A Dropbox ConnectorDocument flows through prepare + index to a READY document."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
doc = _dropbox_doc(
unique_id="db-file-abc",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@@ -52,7 +52,7 @@ async def test_dropbox_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == space_id)
+ select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@@ -63,15 +63,15 @@ async def test_dropbox_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_dropbox_duplicate_content_skipped(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""Re-indexing a Dropbox doc with the same content is skipped (content hash match)."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
user_id = str(db_user.id)
doc = _dropbox_doc(
unique_id="db-dup-file",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
@@ -83,13 +83,13 @@ async def test_dropbox_duplicate_content_skipped(
await service.index(prepared[0], doc)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == space_id)
+ select(Document).filter(Document.workspace_id == space_id)
)
first_doc = result.scalars().first()
assert first_doc is not None
doc2 = _dropbox_doc(
unique_id="db-dup-file",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_gmail_pipeline.py b/surfsense_backend/tests/integration/indexing_pipeline/test_gmail_pipeline.py
index 2026393c5..f0267b583 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_gmail_pipeline.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_gmail_pipeline.py
@@ -17,7 +17,7 @@ pytestmark = pytest.mark.integration
def _gmail_doc(
- *, unique_id: str, search_space_id: int, connector_id: int, user_id: str
+ *, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
"""Build a Gmail-style ConnectorDocument like the real indexer does."""
return ConnectorDocument(
@@ -25,7 +25,7 @@ def _gmail_doc(
source_markdown=f"## Email\n\nBody of {unique_id}",
unique_id=unique_id,
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -38,13 +38,13 @@ def _gmail_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_gmail_pipeline_creates_ready_document(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""A Gmail ConnectorDocument flows through prepare + index to a READY document."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
doc = _gmail_doc(
unique_id="msg-pipeline-1",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@@ -56,7 +56,7 @@ async def test_gmail_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == space_id)
+ select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@@ -68,10 +68,10 @@ async def test_gmail_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_gmail_legacy_doc_migrated_then_reused(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""A legacy Composio Gmail doc is migrated then reused by the pipeline."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
user_id = str(db_user.id)
msg_id = "msg-legacy-gmail"
@@ -85,7 +85,7 @@ async def test_gmail_legacy_doc_migrated_then_reused(
content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash,
source_markdown="## Old content",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"},
@@ -96,7 +96,7 @@ async def test_gmail_legacy_doc_migrated_then_reused(
connector_doc = _gmail_doc(
unique_id=msg_id,
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_index_batch.py b/surfsense_backend/tests/integration/indexing_pipeline/test_index_batch.py
index 855676f61..85cf77902 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_index_batch.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_index_batch.py
@@ -11,21 +11,21 @@ pytestmark = pytest.mark.integration
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_index_batch_creates_ready_documents(
- db_session, db_search_space, make_connector_document, mocker
+ db_session, db_workspace, make_connector_document, mocker
):
"""index_batch prepares and indexes a batch, resulting in READY documents."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
docs = [
make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-batch-1",
- search_space_id=space_id,
+ workspace_id=space_id,
source_markdown="## Email 1\n\nBody",
),
make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-batch-2",
- search_space_id=space_id,
+ workspace_id=space_id,
source_markdown="## Email 2\n\nDifferent body",
),
]
@@ -36,7 +36,7 @@ async def test_index_batch_creates_ready_documents(
assert len(results) == 2
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == space_id)
+ select(Document).filter(Document.workspace_id == space_id)
)
rows = result.scalars().all()
assert len(rows) == 2
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_index_document.py b/surfsense_backend/tests/integration/indexing_pipeline/test_index_document.py
index ee895c61b..14e07a498 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_index_document.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_index_document.py
@@ -13,12 +13,12 @@ pytestmark = pytest.mark.integration
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_sets_status_ready(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""Document status is READY after successful indexing."""
- connector_doc = make_connector_document(search_space_id=db_search_space.id)
+ connector_doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc])
@@ -38,12 +38,12 @@ async def test_sets_status_ready(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_content_is_source_markdown_by_default(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""Document content is set to source_markdown by default."""
- connector_doc = make_connector_document(search_space_id=db_search_space.id)
+ connector_doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc])
@@ -63,12 +63,12 @@ async def test_content_is_source_markdown_by_default(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_content_is_source_markdown_when_custom_content(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
):
"""Document content is set to source_markdown verbatim."""
connector_doc = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="## Raw content",
)
service = IndexingPipelineService(session=db_session)
@@ -90,12 +90,12 @@ async def test_content_is_source_markdown_when_custom_content(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_chunks_written_to_db(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""Chunks derived from source_markdown are persisted in the DB."""
- connector_doc = make_connector_document(search_space_id=db_search_space.id)
+ connector_doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc])
@@ -116,12 +116,12 @@ async def test_chunks_written_to_db(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_embedding_written_to_db(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""Document embedding vector is persisted in the DB after indexing."""
- connector_doc = make_connector_document(search_space_id=db_search_space.id)
+ connector_doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc])
@@ -142,12 +142,12 @@ async def test_embedding_written_to_db(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_updated_at_advances_after_indexing(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""updated_at timestamp is later after indexing than it was at prepare time."""
- connector_doc = make_connector_document(search_space_id=db_search_space.id)
+ connector_doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc])
@@ -172,12 +172,12 @@ async def test_updated_at_advances_after_indexing(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_no_llm_falls_back_to_source_markdown(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
):
"""Content stays deterministic source markdown without an LLM."""
connector_doc = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="## Fallback content",
)
service = IndexingPipelineService(session=db_session)
@@ -200,12 +200,12 @@ async def test_no_llm_falls_back_to_source_markdown(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_source_markdown_used_without_preview(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
):
"""Source markdown is used without fallback preview fields."""
connector_doc = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="## Full raw content",
)
service = IndexingPipelineService(session=db_session)
@@ -227,13 +227,13 @@ async def test_source_markdown_used_without_preview(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_reindex_replaces_old_chunks(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""Re-indexing a document replaces its old chunks rather than appending."""
connector_doc = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="## v1",
)
service = IndexingPipelineService(session=db_session)
@@ -245,7 +245,7 @@ async def test_reindex_replaces_old_chunks(
await service.index(document, connector_doc)
updated_doc = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="## v2",
)
re_prepared = await service.prepare_for_indexing([updated_doc])
@@ -262,12 +262,12 @@ async def test_reindex_replaces_old_chunks(
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
async def test_embedding_error_sets_status_failed(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""Document status is FAILED when embedding raises during indexing."""
- connector_doc = make_connector_document(search_space_id=db_search_space.id)
+ connector_doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc])
@@ -287,12 +287,12 @@ async def test_embedding_error_sets_status_failed(
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
async def test_embedding_error_leaves_no_partial_data(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""A failed indexing attempt leaves no partial embedding or chunks in the DB."""
- connector_doc = make_connector_document(search_space_id=db_search_space.id)
+ connector_doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc])
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_index_editions.py b/surfsense_backend/tests/integration/indexing_pipeline/test_index_editions.py
index 68d5ec0af..26e89435c 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_index_editions.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_index_editions.py
@@ -50,14 +50,12 @@ async def _load_chunks(db_session, document_id):
@pytest.mark.usefixtures("paragraph_chunker")
async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
patched_embed_texts,
):
service = IndexingPipelineService(session=db_session)
- doc_v1 = make_connector_document(
- search_space_id=db_search_space.id, source_markdown=_V1
- )
+ doc_v1 = make_connector_document(workspace_id=db_workspace.id, source_markdown=_V1)
document = await _index(service, doc_v1)
ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)}
@@ -65,7 +63,7 @@ async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
edited = "Intro paragraph.\n\nBody paragraph EDITED.\n\nOutro paragraph."
doc_v2 = make_connector_document(
- search_space_id=db_search_space.id, source_markdown=edited
+ workspace_id=db_workspace.id, source_markdown=edited
)
await _index(service, doc_v2)
@@ -94,22 +92,20 @@ async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
async def test_head_insert_shifts_positions_without_new_rows_for_old_text(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
):
service = IndexingPipelineService(session=db_session)
document = await _index(
service,
- make_connector_document(
- search_space_id=db_search_space.id, source_markdown=_V1
- ),
+ make_connector_document(workspace_id=db_workspace.id, source_markdown=_V1),
)
ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)}
await _index(
service,
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="Brand new opener.\n\n" + _V1,
),
)
@@ -130,22 +126,20 @@ async def test_head_insert_shifts_positions_without_new_rows_for_old_text(
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
async def test_removed_paragraph_is_deleted_and_order_compacts(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
):
service = IndexingPipelineService(session=db_session)
document = await _index(
service,
- make_connector_document(
- search_space_id=db_search_space.id, source_markdown=_V1
- ),
+ make_connector_document(workspace_id=db_workspace.id, source_markdown=_V1),
)
ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)}
await _index(
service,
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="Intro paragraph.\n\nOutro paragraph.",
),
)
@@ -162,7 +156,7 @@ async def test_removed_paragraph_is_deleted_and_order_compacts(
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
async def test_kill_switch_falls_back_to_full_replace(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
monkeypatch,
):
@@ -171,9 +165,7 @@ async def test_kill_switch_falls_back_to_full_replace(
service = IndexingPipelineService(session=db_session)
document = await _index(
service,
- make_connector_document(
- search_space_id=db_search_space.id, source_markdown=_V1
- ),
+ make_connector_document(workspace_id=db_workspace.id, source_markdown=_V1),
)
ids_v1 = {c.id for c in await _load_chunks(db_session, document.id)}
@@ -181,7 +173,7 @@ async def test_kill_switch_falls_back_to_full_replace(
await _index(
service,
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown=_V1 + "\n\nAppended paragraph.",
),
)
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_local_folder_pipeline.py b/surfsense_backend/tests/integration/indexing_pipeline/test_local_folder_pipeline.py
index e37c34388..76ee82209 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_local_folder_pipeline.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_local_folder_pipeline.py
@@ -14,8 +14,8 @@ from app.db import (
DocumentType,
DocumentVersion,
Folder,
- SearchSpace,
User,
+ Workspace,
)
pytestmark = pytest.mark.integration
@@ -66,7 +66,7 @@ class TestFullIndexer:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""I1: Single new .md file is indexed with status READY."""
@@ -76,7 +76,7 @@ class TestFullIndexer:
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -90,7 +90,7 @@ class TestFullIndexer:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
)
@@ -106,7 +106,7 @@ class TestFullIndexer:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""I2: Second run on unchanged directory creates no new documents."""
@@ -116,7 +116,7 @@ class TestFullIndexer:
count1, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -125,7 +125,7 @@ class TestFullIndexer:
count2, _, _, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -139,7 +139,7 @@ class TestFullIndexer:
.select_from(Document)
.where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
).scalar_one()
@@ -150,7 +150,7 @@ class TestFullIndexer:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""I3: Modified file content triggers re-index and creates a version."""
@@ -161,7 +161,7 @@ class TestFullIndexer:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -172,7 +172,7 @@ class TestFullIndexer:
count, _, _, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -187,7 +187,7 @@ class TestFullIndexer:
.join(Document)
.where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
)
@@ -201,7 +201,7 @@ class TestFullIndexer:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""I4: Deleted file is removed from DB on re-sync."""
@@ -212,7 +212,7 @@ class TestFullIndexer:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -224,7 +224,7 @@ class TestFullIndexer:
.select_from(Document)
.where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
).scalar_one()
@@ -234,7 +234,7 @@ class TestFullIndexer:
await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -247,7 +247,7 @@ class TestFullIndexer:
.select_from(Document)
.where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
).scalar_one()
@@ -258,7 +258,7 @@ class TestFullIndexer:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""I5: Batch mode with a single file only processes that file."""
@@ -270,7 +270,7 @@ class TestFullIndexer:
count, _, _, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -283,7 +283,7 @@ class TestFullIndexer:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
)
@@ -305,7 +305,7 @@ class TestFolderMirroring:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""F1: First sync creates a root Folder and returns root_folder_id."""
@@ -315,7 +315,7 @@ class TestFolderMirroring:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -333,7 +333,7 @@ class TestFolderMirroring:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""F2: Nested dirs create Folder rows with correct parent_id chain."""
@@ -348,7 +348,7 @@ class TestFolderMirroring:
await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -357,7 +357,7 @@ class TestFolderMirroring:
folders = (
(
await db_session.execute(
- select(Folder).where(Folder.search_space_id == db_search_space.id)
+ select(Folder).where(Folder.workspace_id == db_workspace.id)
)
)
.scalars()
@@ -381,7 +381,7 @@ class TestFolderMirroring:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""F3: Re-sync reuses existing Folder rows, no duplicates."""
@@ -393,7 +393,7 @@ class TestFolderMirroring:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -402,7 +402,7 @@ class TestFolderMirroring:
folders_before = (
(
await db_session.execute(
- select(Folder).where(Folder.search_space_id == db_search_space.id)
+ select(Folder).where(Folder.workspace_id == db_workspace.id)
)
)
.scalars()
@@ -412,7 +412,7 @@ class TestFolderMirroring:
await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -422,7 +422,7 @@ class TestFolderMirroring:
folders_after = (
(
await db_session.execute(
- select(Folder).where(Folder.search_space_id == db_search_space.id)
+ select(Folder).where(Folder.workspace_id == db_workspace.id)
)
)
.scalars()
@@ -437,7 +437,7 @@ class TestFolderMirroring:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""F4: Documents get correct folder_id based on their directory."""
@@ -450,7 +450,7 @@ class TestFolderMirroring:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -461,7 +461,7 @@ class TestFolderMirroring:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
)
@@ -485,7 +485,7 @@ class TestFolderMirroring:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""F5: Deleted dir's empty Folder row is cleaned up on re-sync."""
@@ -502,7 +502,7 @@ class TestFolderMirroring:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -517,7 +517,7 @@ class TestFolderMirroring:
await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -539,7 +539,7 @@ class TestFolderMirroring:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""F6: Single-file mode creates missing Folder rows and assigns correct folder_id."""
@@ -549,7 +549,7 @@ class TestFolderMirroring:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -561,7 +561,7 @@ class TestFolderMirroring:
count, _, _, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -597,7 +597,7 @@ class TestFolderMirroring:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""F7: Deleting the only file in a subfolder via batch mode removes empty Folder rows."""
@@ -610,7 +610,7 @@ class TestFolderMirroring:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -626,7 +626,7 @@ class TestFolderMirroring:
await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -656,7 +656,7 @@ class TestBatchMode:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
patched_batch_sessions,
):
@@ -669,7 +669,7 @@ class TestBatchMode:
count, failed, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -689,7 +689,7 @@ class TestBatchMode:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
)
@@ -707,7 +707,7 @@ class TestBatchMode:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
patched_batch_sessions,
):
@@ -720,7 +720,7 @@ class TestBatchMode:
count, failed, _, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -740,7 +740,7 @@ class TestBatchMode:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
)
@@ -762,7 +762,7 @@ class TestPipelineIntegration:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
mocker,
):
"""P1: LOCAL_FOLDER_FILE ConnectorDocument through prepare+index to READY."""
@@ -776,7 +776,7 @@ class TestPipelineIntegration:
source_markdown="## Local file\n\nContent from disk.",
unique_id="test-folder:test.md",
document_type=DocumentType.LOCAL_FOLDER_FILE,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
connector_id=None,
created_by_id=str(db_user.id),
)
@@ -794,7 +794,7 @@ class TestPipelineIntegration:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
)
@@ -816,7 +816,7 @@ class TestDirectConvert:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""DC1: CSV file is indexed as a markdown table, not raw comma-separated text."""
@@ -826,7 +826,7 @@ class TestDirectConvert:
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -839,7 +839,7 @@ class TestDirectConvert:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
).scalar_one()
@@ -853,7 +853,7 @@ class TestDirectConvert:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""DC2: TSV file is indexed as a markdown table."""
@@ -865,7 +865,7 @@ class TestDirectConvert:
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -878,7 +878,7 @@ class TestDirectConvert:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
).scalar_one()
@@ -891,7 +891,7 @@ class TestDirectConvert:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""DC3: HTML file is indexed as clean markdown, not raw HTML."""
@@ -901,7 +901,7 @@ class TestDirectConvert:
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -914,7 +914,7 @@ class TestDirectConvert:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
).scalar_one()
@@ -927,7 +927,7 @@ class TestDirectConvert:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""DC4: CSV via single-file batch mode also produces a markdown table."""
@@ -937,7 +937,7 @@ class TestDirectConvert:
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -951,7 +951,7 @@ class TestDirectConvert:
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
- Document.search_space_id == db_search_space.id,
+ Document.workspace_id == db_workspace.id,
)
)
).scalar_one()
@@ -984,7 +984,7 @@ class TestEtlCredits:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""CR1: Successful full-scan sync debits user.credit_micros_balance."""
@@ -998,7 +998,7 @@ class TestEtlCredits:
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1017,7 +1017,7 @@ class TestEtlCredits:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""CR2: Full-scan skips file when the wallet is empty."""
@@ -1030,7 +1030,7 @@ class TestEtlCredits:
count, _skipped, _root_folder_id, _err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1048,7 +1048,7 @@ class TestEtlCredits:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""CR3: Single-file mode debits balance on success."""
@@ -1062,7 +1062,7 @@ class TestEtlCredits:
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1082,7 +1082,7 @@ class TestEtlCredits:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""CR4: Single-file mode skips file when the wallet is empty."""
@@ -1095,7 +1095,7 @@ class TestEtlCredits:
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1116,7 +1116,7 @@ class TestEtlCredits:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""CR5: Re-syncing an unchanged file does not consume additional credit."""
@@ -1129,7 +1129,7 @@ class TestEtlCredits:
count1, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1142,7 +1142,7 @@ class TestEtlCredits:
count2, _, _, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1160,7 +1160,7 @@ class TestEtlCredits:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
patched_batch_sessions,
):
@@ -1177,7 +1177,7 @@ class TestEtlCredits:
count, failed, _root_folder_id, _err = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1209,7 +1209,7 @@ class TestIndexingProgressFlag:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""IP1: Full-scan mode clears indexing_in_progress after completion."""
@@ -1219,7 +1219,7 @@ class TestIndexingProgressFlag:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1237,7 +1237,7 @@ class TestIndexingProgressFlag:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""IP2: Single-file (Chokidar) mode clears indexing_in_progress after completion."""
@@ -1246,7 +1246,7 @@ class TestIndexingProgressFlag:
(tmp_path / "root.md").write_text("root")
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1256,7 +1256,7 @@ class TestIndexingProgressFlag:
await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
@@ -1275,7 +1275,7 @@ class TestIndexingProgressFlag:
self,
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
tmp_path: Path,
):
"""IP3: indexing_in_progress is True on the root folder while indexing is running."""
@@ -1294,7 +1294,7 @@ class TestIndexingProgressFlag:
folder = (
await db_session.execute(
select(Folder).where(
- Folder.search_space_id == db_search_space.id,
+ Folder.workspace_id == db_workspace.id,
Folder.parent_id.is_(None),
)
)
@@ -1308,7 +1308,7 @@ class TestIndexingProgressFlag:
try:
_, _, root_folder_id, _ = await index_local_folder(
session=db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user_id=str(db_user.id),
folder_path=str(tmp_path),
folder_name="test-folder",
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_mark_connector_documents_failed.py b/surfsense_backend/tests/integration/indexing_pipeline/test_mark_connector_documents_failed.py
index 9e3feee1e..ab634cb25 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_mark_connector_documents_failed.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_mark_connector_documents_failed.py
@@ -20,14 +20,14 @@ pytestmark = pytest.mark.integration
async def _make_doc(
db_session,
*,
- search_space_id: int,
+ workspace_id: int,
connector_id: int,
user_id: str,
file_id: str,
status: dict,
) -> Document:
uid_hash = compute_identifier_hash(
- DocumentType.GOOGLE_DRIVE_FILE.value, file_id, search_space_id
+ DocumentType.GOOGLE_DRIVE_FILE.value, file_id, workspace_id
)
doc = Document(
title=f"{file_id}.pdf",
@@ -36,7 +36,7 @@ async def _make_doc(
content_hash=hashlib.sha256(f"placeholder:{uid_hash}".encode()).hexdigest(),
unique_identifier_hash=uid_hash,
document_metadata={"google_drive_file_id": file_id},
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
status=status,
@@ -47,11 +47,11 @@ async def _make_doc(
async def test_pending_placeholder_marked_failed(
- db_session, db_search_space, db_connector, db_user
+ db_session, db_workspace, db_connector, db_user
):
doc = await _make_doc(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
connector_id=db_connector.id,
user_id=str(db_user.id),
file_id="file-pending",
@@ -61,7 +61,7 @@ async def test_pending_placeholder_marked_failed(
marked = await mark_connector_documents_failed(
db_session,
document_type=DocumentType.GOOGLE_DRIVE_FILE,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
failures=[("file-pending", "Download/ETL failed: boom")],
)
@@ -72,11 +72,11 @@ async def test_pending_placeholder_marked_failed(
async def test_ready_document_not_clobbered(
- db_session, db_search_space, db_connector, db_user
+ db_session, db_workspace, db_connector, db_user
):
doc = await _make_doc(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
connector_id=db_connector.id,
user_id=str(db_user.id),
file_id="file-ready",
@@ -86,7 +86,7 @@ async def test_ready_document_not_clobbered(
marked = await mark_connector_documents_failed(
db_session,
document_type=DocumentType.GOOGLE_DRIVE_FILE,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
failures=[("file-ready", "should be ignored")],
)
@@ -95,16 +95,16 @@ async def test_ready_document_not_clobbered(
assert DocumentStatus.is_state(doc.status, DocumentStatus.READY)
-async def test_missing_document_is_noop(db_session, db_search_space):
+async def test_missing_document_is_noop(db_session, db_workspace):
marked = await mark_connector_documents_failed(
db_session,
document_type=DocumentType.GOOGLE_DRIVE_FILE,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
failures=[("does-not-exist", "reason")],
)
assert marked == 0
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
assert result.scalars().first() is None
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_migrate_legacy_docs.py b/surfsense_backend/tests/integration/indexing_pipeline/test_migrate_legacy_docs.py
index 8fc0e7586..1580a1736 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_migrate_legacy_docs.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_migrate_legacy_docs.py
@@ -14,10 +14,10 @@ pytestmark = pytest.mark.integration
async def test_legacy_composio_gmail_doc_migrated_in_db(
- db_session, db_search_space, db_user, make_connector_document
+ db_session, db_workspace, db_user, make_connector_document
):
"""A Composio Gmail doc in the DB gets its hash and type updated to native."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
user_id = str(db_user.id)
unique_id = "msg-legacy-123"
@@ -34,7 +34,7 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
content="legacy content",
content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash,
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"},
@@ -46,7 +46,7 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id=unique_id,
- search_space_id=space_id,
+ workspace_id=space_id,
)
service = IndexingPipelineService(session=db_session)
@@ -59,33 +59,31 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
assert reloaded.document_type == DocumentType.GOOGLE_GMAIL_CONNECTOR
-async def test_no_legacy_doc_is_noop(
- db_session, db_search_space, make_connector_document
-):
+async def test_no_legacy_doc_is_noop(db_session, db_workspace, make_connector_document):
"""When no legacy document exists, migrate_legacy_docs does nothing."""
connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
unique_id="evt-no-legacy",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
service = IndexingPipelineService(session=db_session)
await service.migrate_legacy_docs([connector_doc])
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
assert result.scalars().all() == []
async def test_non_google_type_is_skipped(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""migrate_legacy_docs skips ConnectorDocuments that are not Google types."""
connector_doc = make_connector_document(
document_type=DocumentType.CLICKUP_CONNECTOR,
unique_id="task-1",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
service = IndexingPipelineService(session=db_session)
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_onedrive_pipeline.py b/surfsense_backend/tests/integration/indexing_pipeline/test_onedrive_pipeline.py
index e368ec256..a5000e197 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_onedrive_pipeline.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_onedrive_pipeline.py
@@ -14,14 +14,14 @@ pytestmark = pytest.mark.integration
def _onedrive_doc(
- *, unique_id: str, search_space_id: int, connector_id: int, user_id: str
+ *, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
return ConnectorDocument(
title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}",
unique_id=unique_id,
document_type=DocumentType.ONEDRIVE_FILE,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@@ -34,13 +34,13 @@ def _onedrive_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_onedrive_pipeline_creates_ready_document(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""A OneDrive ConnectorDocument flows through prepare + index to a READY document."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
doc = _onedrive_doc(
unique_id="od-file-abc",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@@ -52,7 +52,7 @@ async def test_onedrive_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == space_id)
+ select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@@ -63,15 +63,15 @@ async def test_onedrive_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_onedrive_duplicate_content_skipped(
- db_session, db_search_space, db_connector, db_user, mocker
+ db_session, db_workspace, db_connector, db_user, mocker
):
"""Re-indexing a OneDrive doc with the same content is skipped (content hash match)."""
- space_id = db_search_space.id
+ space_id = db_workspace.id
user_id = str(db_user.id)
doc = _onedrive_doc(
unique_id="od-dup-file",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
@@ -83,13 +83,13 @@ async def test_onedrive_duplicate_content_skipped(
await service.index(prepared[0], doc)
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == space_id)
+ select(Document).filter(Document.workspace_id == space_id)
)
first_doc = result.scalars().first()
assert first_doc is not None
doc2 = _onedrive_doc(
unique_id="od-dup-file",
- search_space_id=space_id,
+ workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_prepare_for_indexing.py b/surfsense_backend/tests/integration/indexing_pipeline/test_prepare_for_indexing.py
index 4b6662fc8..70d1d7a24 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_prepare_for_indexing.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_prepare_for_indexing.py
@@ -11,10 +11,10 @@ pytestmark = pytest.mark.integration
async def test_new_document_is_persisted_with_pending_status(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""A new document is created in the DB with PENDING status and correct markdown."""
- doc = make_connector_document(search_space_id=db_search_space.id)
+ doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
results = await service.prepare_for_indexing([doc])
@@ -35,12 +35,12 @@ async def test_new_document_is_persisted_with_pending_status(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_unchanged_ready_document_is_skipped(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""A READY document with unchanged content is not returned for re-indexing."""
- doc = make_connector_document(search_space_id=db_search_space.id)
+ doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
# Index fully so the document reaches ready state
@@ -56,13 +56,13 @@ async def test_unchanged_ready_document_is_skipped(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_title_only_change_updates_title_in_db(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""A title-only change updates the DB title without re-queuing the document."""
original = make_connector_document(
- search_space_id=db_search_space.id, title="Original Title"
+ workspace_id=db_workspace.id, title="Original Title"
)
service = IndexingPipelineService(session=db_session)
@@ -71,7 +71,7 @@ async def test_title_only_change_updates_title_in_db(
await service.index(prepared[0], original)
renamed = make_connector_document(
- search_space_id=db_search_space.id, title="Updated Title"
+ workspace_id=db_workspace.id, title="Updated Title"
)
results = await service.prepare_for_indexing([renamed])
@@ -86,11 +86,11 @@ async def test_title_only_change_updates_title_in_db(
async def test_changed_content_is_returned_for_reprocessing(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""A document with changed content is returned for re-indexing with updated markdown."""
original = make_connector_document(
- search_space_id=db_search_space.id, source_markdown="## v1"
+ workspace_id=db_workspace.id, source_markdown="## v1"
)
service = IndexingPipelineService(session=db_session)
@@ -98,7 +98,7 @@ async def test_changed_content_is_returned_for_reprocessing(
original_id = first[0].id
updated = make_connector_document(
- search_space_id=db_search_space.id, source_markdown="## v2"
+ workspace_id=db_workspace.id, source_markdown="## v2"
)
results = await service.prepare_for_indexing([updated])
@@ -115,24 +115,24 @@ async def test_changed_content_is_returned_for_reprocessing(
async def test_all_documents_in_batch_are_persisted(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""All documents in a batch are persisted and returned."""
docs = [
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="id-1",
title="Doc 1",
source_markdown="## Content 1",
),
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="id-2",
title="Doc 2",
source_markdown="## Content 2",
),
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="id-3",
title="Doc 3",
source_markdown="## Content 3",
@@ -145,7 +145,7 @@ async def test_all_documents_in_batch_are_persisted(
assert len(results) == 3
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
rows = result.scalars().all()
@@ -153,10 +153,10 @@ async def test_all_documents_in_batch_are_persisted(
async def test_duplicate_in_batch_is_persisted_once(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""The same document passed twice in a batch is only persisted once."""
- doc = make_connector_document(search_space_id=db_search_space.id)
+ doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
results = await service.prepare_for_indexing([doc, doc])
@@ -164,7 +164,7 @@ async def test_duplicate_in_batch_is_persisted_once(
assert len(results) == 1
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
rows = result.scalars().all()
@@ -172,11 +172,11 @@ async def test_duplicate_in_batch_is_persisted_once(
async def test_created_by_id_is_persisted(
- db_session, db_user, db_search_space, make_connector_document
+ db_session, db_user, db_workspace, make_connector_document
):
"""created_by_id from the connector document is persisted on the DB row."""
doc = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
created_by_id=str(db_user.id),
)
service = IndexingPipelineService(session=db_session)
@@ -193,11 +193,11 @@ async def test_created_by_id_is_persisted(
async def test_metadata_is_updated_when_content_changes(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""document_metadata is overwritten with the latest metadata when content changes."""
original = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="## v1",
metadata={"status": "in_progress"},
)
@@ -207,7 +207,7 @@ async def test_metadata_is_updated_when_content_changes(
document_id = first[0].id
updated = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
source_markdown="## v2",
metadata={"status": "done"},
)
@@ -222,12 +222,10 @@ async def test_metadata_is_updated_when_content_changes(
async def test_updated_at_advances_when_title_only_changes(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""updated_at advances even when only the title changes."""
- original = make_connector_document(
- search_space_id=db_search_space.id, title="Old Title"
- )
+ original = make_connector_document(workspace_id=db_workspace.id, title="Old Title")
service = IndexingPipelineService(session=db_session)
first = await service.prepare_for_indexing([original])
@@ -238,9 +236,7 @@ async def test_updated_at_advances_when_title_only_changes(
)
updated_at_v1 = result.scalars().first().updated_at
- renamed = make_connector_document(
- search_space_id=db_search_space.id, title="New Title"
- )
+ renamed = make_connector_document(workspace_id=db_workspace.id, title="New Title")
await service.prepare_for_indexing([renamed])
result = await db_session.execute(
@@ -252,11 +248,11 @@ async def test_updated_at_advances_when_title_only_changes(
async def test_updated_at_advances_when_content_changes(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""updated_at advances when document content changes."""
original = make_connector_document(
- search_space_id=db_search_space.id, source_markdown="## v1"
+ workspace_id=db_workspace.id, source_markdown="## v1"
)
service = IndexingPipelineService(session=db_session)
@@ -269,7 +265,7 @@ async def test_updated_at_advances_when_content_changes(
updated_at_v1 = result.scalars().first().updated_at
updated = make_connector_document(
- search_space_id=db_search_space.id, source_markdown="## v2"
+ workspace_id=db_workspace.id, source_markdown="## v2"
)
await service.prepare_for_indexing([updated])
@@ -282,16 +278,16 @@ async def test_updated_at_advances_when_content_changes(
async def test_same_content_from_different_source_skipped_in_single_batch(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""Two documents with identical content in the same batch result in only one being persisted."""
first = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="source-a",
source_markdown="## Shared content",
)
second = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="source-b",
source_markdown="## Shared content",
)
@@ -302,22 +298,22 @@ async def test_same_content_from_different_source_skipped_in_single_batch(
assert len(results) == 1
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
assert len(result.scalars().all()) == 1
async def test_same_content_from_different_source_is_skipped(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""A document with content identical to an already-indexed document is skipped."""
first = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="source-a",
source_markdown="## Shared content",
)
second = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="source-b",
source_markdown="## Shared content",
)
@@ -329,7 +325,7 @@ async def test_same_content_from_different_source_is_skipped(
assert results == []
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
assert len(result.scalars().all()) == 1
@@ -337,12 +333,12 @@ async def test_same_content_from_different_source_is_skipped(
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
async def test_failed_document_with_unchanged_content_is_requeued(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
mocker,
):
"""A FAILED document with unchanged content is re-queued as PENDING on the next run."""
- doc = make_connector_document(search_space_id=db_search_space.id)
+ doc = make_connector_document(workspace_id=db_workspace.id)
service = IndexingPipelineService(session=db_session)
# First run: document is created and indexing crashes, so status becomes failed.
@@ -372,11 +368,11 @@ async def test_failed_document_with_unchanged_content_is_requeued(
async def test_title_and_content_change_updates_both_and_returns_document(
- db_session, db_search_space, make_connector_document
+ db_session, db_workspace, make_connector_document
):
"""When both title and content change, both are updated and the document is returned for re-indexing."""
original = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Original Title",
source_markdown="## v1",
)
@@ -386,7 +382,7 @@ async def test_title_and_content_change_updates_both_and_returns_document(
original_id = first[0].id
updated = make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
title="Updated Title",
source_markdown="## v2",
)
@@ -406,7 +402,7 @@ async def test_title_and_content_change_updates_both_and_returns_document(
async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_persisted(
db_session,
- db_search_space,
+ db_workspace,
make_connector_document,
monkeypatch,
):
@@ -416,17 +412,17 @@ async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_pers
"""
docs = [
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="good-1",
source_markdown="## Good doc 1",
),
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="will-fail",
source_markdown="## Bad doc",
),
make_connector_document(
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
unique_id="good-2",
source_markdown="## Good doc 2",
),
@@ -448,6 +444,6 @@ async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_pers
assert len(results) == 2
result = await db_session.execute(
- select(Document).filter(Document.search_space_id == db_search_space.id)
+ select(Document).filter(Document.workspace_id == db_workspace.id)
)
assert len(result.scalars().all()) == 2
diff --git a/surfsense_backend/tests/integration/notifications/test_base_handler.py b/surfsense_backend/tests/integration/notifications/test_base_handler.py
index ef7d9ee6c..56092d19a 100644
--- a/surfsense_backend/tests/integration/notifications/test_base_handler.py
+++ b/surfsense_backend/tests/integration/notifications/test_base_handler.py
@@ -1,7 +1,7 @@
"""Behavior guard for the shared find/upsert/update logic (BaseNotificationHandler).
Uses the connector-indexing handler instance to drive the base methods against
-real Postgres, pinning upsert dedup, search-space scoping, and status stamping.
+real Postgres, pinning upsert dedup, workspace scoping, and status stamping.
"""
from __future__ import annotations
@@ -10,7 +10,7 @@ import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db import SearchSpace, User
+from app.db import User, Workspace
from app.notifications.persistence import Notification
from app.notifications.service import NotificationService
@@ -22,7 +22,7 @@ handler = NotificationService.connector_indexing
async def test_find_or_create_creates_with_progress_metadata(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""Creating a notification seeds operation id, in-progress status, and start time."""
notification = await handler.find_or_create_notification(
@@ -31,7 +31,7 @@ async def test_find_or_create_creates_with_progress_metadata(
operation_id="op-create",
title="Title",
message="Message",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
assert notification.notification_metadata["operation_id"] == "op-create"
@@ -42,7 +42,7 @@ async def test_find_or_create_creates_with_progress_metadata(
async def test_find_or_create_upserts_same_operation(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""Reusing an operation id updates the same row instead of creating a duplicate."""
first = await handler.find_or_create_notification(
@@ -51,7 +51,7 @@ async def test_find_or_create_upserts_same_operation(
operation_id="op-upsert",
title="First",
message="First message",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
second = await handler.find_or_create_notification(
@@ -60,7 +60,7 @@ async def test_find_or_create_upserts_same_operation(
operation_id="op-upsert",
title="Second",
message="Second message",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
assert second.id == first.id
@@ -76,22 +76,22 @@ async def test_find_or_create_upserts_same_operation(
assert count == 1
-async def test_find_by_operation_is_scoped_to_search_space(
+async def test_find_by_operation_is_scoped_to_workspace(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- """Operation-id lookup is scoped per search space, so other spaces don't match."""
+ """Operation-id lookup is scoped per workspace, so other spaces don't match."""
await handler.find_or_create_notification(
session=db_session,
user_id=db_user.id,
operation_id="op-scoped",
title="Title",
message="Message",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
- other_space = SearchSpace(name="Other Space", user_id=db_user.id)
+ other_space = Workspace(name="Other Space", user_id=db_user.id)
db_session.add(other_space)
await db_session.flush()
@@ -99,7 +99,7 @@ async def test_find_by_operation_is_scoped_to_search_space(
session=db_session,
user_id=db_user.id,
operation_id="op-scoped",
- search_space_id=other_space.id,
+ workspace_id=other_space.id,
)
assert found_other is None
@@ -107,7 +107,7 @@ async def test_find_by_operation_is_scoped_to_search_space(
session=db_session,
user_id=db_user.id,
operation_id="op-scoped",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
assert found_same is not None
@@ -115,7 +115,7 @@ async def test_find_by_operation_is_scoped_to_search_space(
async def test_update_notification_completed_stamps_completed_at(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""Completing a notification stamps completed_at and merges metadata updates."""
notification = await handler.find_or_create_notification(
@@ -124,7 +124,7 @@ async def test_update_notification_completed_stamps_completed_at(
operation_id="op-complete",
title="Title",
message="Message",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
updated = await handler.update_notification(
@@ -142,7 +142,7 @@ async def test_update_notification_completed_stamps_completed_at(
async def test_update_notification_failed_stamps_completed_at(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""Failing a notification also stamps completed_at for the terminal state."""
notification = await handler.find_or_create_notification(
@@ -151,7 +151,7 @@ async def test_update_notification_failed_stamps_completed_at(
operation_id="op-fail",
title="Title",
message="Message",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
updated = await handler.update_notification(
diff --git a/surfsense_backend/tests/integration/notifications/test_comment_reply_handler.py b/surfsense_backend/tests/integration/notifications/test_comment_reply_handler.py
index 894f036f0..50d85ad5f 100644
--- a/surfsense_backend/tests/integration/notifications/test_comment_reply_handler.py
+++ b/surfsense_backend/tests/integration/notifications/test_comment_reply_handler.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db import SearchSpace, User
+from app.db import User, Workspace
from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration
@@ -13,7 +13,7 @@ pytestmark = pytest.mark.integration
handler = NotificationService.comment_reply
-async def _notify(db_session, db_user, db_search_space, *, reply_id=1, preview="hi"):
+async def _notify(db_session, db_user, db_workspace, *, reply_id=1, preview="hi"):
"""Raise a comment-reply notification for the assertions in the tests below."""
return await handler.notify_comment_reply(
session=db_session,
@@ -28,15 +28,15 @@ async def _notify(db_session, db_user, db_search_space, *, reply_id=1, preview="
author_avatar_url=None,
author_email="bob@surfsense.net",
content_preview=preview,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
async def test_comment_reply_title_and_message(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A reply notification names the author and carries the comment preview."""
- notification = await _notify(db_session, db_user, db_search_space, preview="thanks")
+ notification = await _notify(db_session, db_user, db_workspace, preview="thanks")
assert notification.type == "comment_reply"
assert notification.title == "Bob replied in a thread"
@@ -44,21 +44,19 @@ async def test_comment_reply_title_and_message(
async def test_comment_reply_truncates_long_preview(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A long comment preview is truncated in the reply message."""
- notification = await _notify(
- db_session, db_user, db_search_space, preview="y" * 150
- )
+ notification = await _notify(db_session, db_user, db_workspace, preview="y" * 150)
assert notification.message == "y" * 100 + "..."
async def test_comment_reply_is_idempotent(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Re-notifying the same reply id reuses the existing notification row."""
- first = await _notify(db_session, db_user, db_search_space, reply_id=5)
- second = await _notify(db_session, db_user, db_search_space, reply_id=5)
+ first = await _notify(db_session, db_user, db_workspace, reply_id=5)
+ second = await _notify(db_session, db_user, db_workspace, reply_id=5)
assert second.id == first.id
diff --git a/surfsense_backend/tests/integration/notifications/test_connector_indexing_handler.py b/surfsense_backend/tests/integration/notifications/test_connector_indexing_handler.py
index a882716b9..b3629ca70 100644
--- a/surfsense_backend/tests/integration/notifications/test_connector_indexing_handler.py
+++ b/surfsense_backend/tests/integration/notifications/test_connector_indexing_handler.py
@@ -10,7 +10,7 @@ from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db import SearchSpace, User
+from app.db import User, Workspace
from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration
@@ -19,7 +19,7 @@ pytestmark = pytest.mark.integration
async def test_indexing_started_opens_notification(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""Starting indexing opens an unread notification with connecting-stage metadata."""
notification = await NotificationService.connector_indexing.notify_indexing_started(
@@ -28,7 +28,7 @@ async def test_indexing_started_opens_notification(
connector_id=42,
connector_name="Notion - My Workspace",
connector_type="NOTION_CONNECTOR",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
assert notification.id is not None
@@ -50,7 +50,7 @@ async def test_indexing_started_opens_notification(
async def _started(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
*,
connector_name: str = "Notion - My Workspace",
):
@@ -61,17 +61,17 @@ async def _started(
connector_id=42,
connector_name=connector_name,
connector_type="NOTION_CONNECTOR",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
async def test_indexing_progress_reports_stage_and_percent(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""Progress updates surface the stage message and compute a percent complete."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
updated = await NotificationService.connector_indexing.notify_indexing_progress(
session=db_session,
@@ -93,10 +93,10 @@ async def test_indexing_progress_reports_stage_and_percent(
async def test_indexing_completed_clean_success(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""A clean multi-file sync reports ready/completed with plural wording."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session,
@@ -113,10 +113,10 @@ async def test_indexing_completed_clean_success(
async def test_indexing_completed_singular_file(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""A single synced file uses singular 'file' wording."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session,
@@ -130,10 +130,10 @@ async def test_indexing_completed_singular_file(
async def test_indexing_completed_nothing_to_sync(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""Completing with nothing new reports 'Already up to date!'."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session,
@@ -149,10 +149,10 @@ async def test_indexing_completed_nothing_to_sync(
async def test_indexing_completed_hard_failure(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""An error with nothing synced reports a hard failure."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session,
@@ -170,10 +170,10 @@ async def test_indexing_completed_hard_failure(
async def test_indexing_completed_partial_with_error_note(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""An error after partial progress still completes, with an appended note."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session,
@@ -190,10 +190,10 @@ async def test_indexing_completed_partial_with_error_note(
async def test_retry_progress_frames_delay_as_providers(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""A retry message frames the delay as the provider's, using its short name."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
retry = await NotificationService.connector_indexing.notify_retry_progress(
session=db_session,
@@ -214,10 +214,10 @@ async def test_retry_progress_frames_delay_as_providers(
async def test_retry_progress_shows_wait_and_synced_count(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
"""A retry surfaces the wait time and how many items synced so far."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
retry = await NotificationService.connector_indexing.notify_retry_progress(
session=db_session,
diff --git a/surfsense_backend/tests/integration/notifications/test_document_processing_handler.py b/surfsense_backend/tests/integration/notifications/test_document_processing_handler.py
index 5ca560f11..806c6d331 100644
--- a/surfsense_backend/tests/integration/notifications/test_document_processing_handler.py
+++ b/surfsense_backend/tests/integration/notifications/test_document_processing_handler.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db import SearchSpace, User
+from app.db import User, Workspace
from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration
@@ -13,22 +13,22 @@ pytestmark = pytest.mark.integration
handler = NotificationService.document_processing
-async def _started(db_session, db_user, db_search_space, *, name="report.pdf"):
+async def _started(db_session, db_user, db_workspace, *, name="report.pdf"):
"""Open a document-processing notification to update in the tests below."""
return await handler.notify_processing_started(
session=db_session,
user_id=db_user.id,
document_type="FILE",
document_name=name,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
async def test_processing_started_queues(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Starting processing queues a notification in the 'queued' stage."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
assert notification.type == "document_processing"
assert notification.title == "Processing: report.pdf"
@@ -37,10 +37,10 @@ async def test_processing_started_queues(
async def test_processing_progress_maps_stage(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A progress update maps the stage to its user-facing message."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
updated = await handler.notify_processing_progress(
session=db_session, notification=notification, stage="parsing"
@@ -51,10 +51,10 @@ async def test_processing_progress_maps_stage(
async def test_processing_completed_success(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Successful processing reports ready/searchable and a completed status."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
done = await handler.notify_processing_completed(
session=db_session, notification=notification, document_id=99
@@ -66,10 +66,10 @@ async def test_processing_completed_success(
async def test_processing_completed_failure(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Failed processing reports a failed status with the error in the message."""
- notification = await _started(db_session, db_user, db_search_space)
+ notification = await _started(db_session, db_user, db_workspace)
done = await handler.notify_processing_completed(
session=db_session, notification=notification, error_message="bad file"
@@ -81,7 +81,7 @@ async def test_processing_completed_failure(
async def test_processing_started_truncates_long_filename(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A long filename is truncated in the title but kept in metadata."""
long_name = "a" * 250
@@ -91,7 +91,7 @@ async def test_processing_started_truncates_long_filename(
user_id=db_user.id,
document_type="FILE",
document_name=long_name,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
assert len(notification.title) <= 200
diff --git a/surfsense_backend/tests/integration/notifications/test_inbox_api.py b/surfsense_backend/tests/integration/notifications/test_inbox_api.py
index 524a0ba60..4ab481754 100644
--- a/surfsense_backend/tests/integration/notifications/test_inbox_api.py
+++ b/surfsense_backend/tests/integration/notifications/test_inbox_api.py
@@ -28,14 +28,14 @@ async def _seed(
title: str = "Title",
message: str = "Message",
read: bool = False,
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
metadata: dict | None = None,
created_at: datetime | None = None,
) -> Notification:
"""Insert a notification row directly for the API tests to read back."""
notification = Notification(
user_id=user.id,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
type=type,
title=title,
message=message,
diff --git a/surfsense_backend/tests/integration/notifications/test_insufficient_credits_handler.py b/surfsense_backend/tests/integration/notifications/test_insufficient_credits_handler.py
index bdfa1b30c..2c1ec32a8 100644
--- a/surfsense_backend/tests/integration/notifications/test_insufficient_credits_handler.py
+++ b/surfsense_backend/tests/integration/notifications/test_insufficient_credits_handler.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db import SearchSpace, User
+from app.db import User, Workspace
from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration
@@ -14,7 +14,7 @@ handler = NotificationService.insufficient_credits
async def test_insufficient_credits_message_and_action(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""An insufficient-credits notification states cost and carries a buy-credits link."""
notification = await handler.notify_insufficient_credits(
@@ -22,7 +22,7 @@ async def test_insufficient_credits_message_and_action(
user_id=db_user.id,
document_name="short.pdf",
document_type="FILE",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
balance_micros=250_000,
required_micros=1_000_000,
)
@@ -36,12 +36,12 @@ async def test_insufficient_credits_message_and_action(
assert notification.notification_metadata["status"] == "failed"
assert notification.notification_metadata["action_label"] == "Buy credits"
assert notification.notification_metadata["action_url"] == (
- f"/dashboard/{db_search_space.id}/buy-more"
+ f"/dashboard/{db_workspace.id}/buy-more"
)
async def test_insufficient_credits_truncates_long_name(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A long document name is truncated in the notification title."""
long_name = "a" * 50
@@ -51,7 +51,7 @@ async def test_insufficient_credits_truncates_long_name(
user_id=db_user.id,
document_name=long_name,
document_type="FILE",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
balance_micros=250_000,
required_micros=1_000_000,
)
diff --git a/surfsense_backend/tests/integration/notifications/test_mention_handler.py b/surfsense_backend/tests/integration/notifications/test_mention_handler.py
index 3254d737c..59bb5c1ad 100644
--- a/surfsense_backend/tests/integration/notifications/test_mention_handler.py
+++ b/surfsense_backend/tests/integration/notifications/test_mention_handler.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db import SearchSpace, User
+from app.db import User, Workspace
from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration
@@ -13,7 +13,7 @@ pytestmark = pytest.mark.integration
handler = NotificationService.mention
-async def _notify(db_session, db_user, db_search_space, *, mention_id=1, preview="hi"):
+async def _notify(db_session, db_user, db_workspace, *, mention_id=1, preview="hi"):
"""Raise an @mention notification for the assertions in the tests below."""
return await handler.notify_new_mention(
session=db_session,
@@ -28,15 +28,15 @@ async def _notify(db_session, db_user, db_search_space, *, mention_id=1, preview
author_avatar_url=None,
author_email="alice@surfsense.net",
content_preview=preview,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
)
async def test_new_mention_title_and_message(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A mention notification names the author and carries the comment preview."""
- notification = await _notify(db_session, db_user, db_search_space, preview="hello")
+ notification = await _notify(db_session, db_user, db_workspace, preview="hello")
assert notification.type == "new_mention"
assert notification.title == "Alice mentioned you"
@@ -44,21 +44,19 @@ async def test_new_mention_title_and_message(
async def test_new_mention_truncates_long_preview(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A long comment preview is truncated in the mention message."""
- notification = await _notify(
- db_session, db_user, db_search_space, preview="x" * 150
- )
+ notification = await _notify(db_session, db_user, db_workspace, preview="x" * 150)
assert notification.message == "x" * 100 + "..."
async def test_new_mention_is_idempotent(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Re-notifying the same mention id reuses the existing notification row."""
- first = await _notify(db_session, db_user, db_search_space, mention_id=7)
- second = await _notify(db_session, db_user, db_search_space, mention_id=7)
+ first = await _notify(db_session, db_user, db_workspace, mention_id=7)
+ second = await _notify(db_session, db_user, db_workspace, mention_id=7)
assert second.id == first.id
diff --git a/surfsense_backend/tests/integration/podcasts/conftest.py b/surfsense_backend/tests/integration/podcasts/conftest.py
index 067924ad5..5f29d9d3c 100644
--- a/surfsense_backend/tests/integration/podcasts/conftest.py
+++ b/surfsense_backend/tests/integration/podcasts/conftest.py
@@ -26,7 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.app import app, limiter
from app.auth.context import AuthContext
from app.config import config as app_config
-from app.db import SearchSpace, User, get_async_session
+from app.db import User, Workspace, get_async_session
from app.podcasts.persistence import Podcast, PodcastStatus
from app.podcasts.schemas import (
DurationTarget,
@@ -39,7 +39,7 @@ from app.podcasts.schemas import (
)
from app.podcasts.service import PodcastService
from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech
-from app.routes.search_spaces_routes import create_default_roles_and_membership
+from app.routes.workspaces_routes import create_default_roles_and_membership
from app.users import get_auth_context
pytestmark = pytest.mark.integration
@@ -248,14 +248,14 @@ def make_podcast(db_session: AsyncSession):
async def _make(
*,
- search_space_id: int,
+ workspace_id: int,
status: PodcastStatus = PodcastStatus.AWAITING_BRIEF,
title: str = "Test Podcast",
thread_id: int | None = None,
) -> Podcast:
service = PodcastService(db_session)
podcast = await service.create(
- title=title, search_space_id=search_space_id, thread_id=thread_id
+ title=title, workspace_id=workspace_id, thread_id=thread_id
)
if status is PodcastStatus.PENDING:
await db_session.flush()
@@ -298,7 +298,7 @@ def act_as():
@pytest_asyncio.fixture
async def db_other_user(db_session: AsyncSession) -> User:
- """A second user who is not a member of ``db_search_space``."""
+ """A second user who is not a member of ``db_workspace``."""
user = User(
id=uuid.uuid4(),
email="stranger@surfsense.net",
@@ -317,9 +317,9 @@ async def foreign_podcast(
db_session: AsyncSession, db_other_user: User, make_podcast
) -> Podcast:
"""A podcast in a space owned by the other user, invisible to db_user."""
- space = SearchSpace(name="Stranger Space", user_id=db_other_user.id)
+ space = Workspace(name="Stranger Space", user_id=db_other_user.id)
db_session.add(space)
await db_session.flush()
await create_default_roles_and_membership(db_session, space.id, db_other_user.id)
await db_session.flush()
- return await make_podcast(search_space_id=space.id, title="Foreign")
+ return await make_podcast(workspace_id=space.id, title="Foreign")
diff --git a/surfsense_backend/tests/integration/podcasts/test_brief_gate.py b/surfsense_backend/tests/integration/podcasts/test_brief_gate.py
index 46d97172d..752e1a043 100644
--- a/surfsense_backend/tests/integration/podcasts/test_brief_gate.py
+++ b/surfsense_backend/tests/integration/podcasts/test_brief_gate.py
@@ -14,12 +14,12 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
-async def _create(client, search_space_id: int) -> dict:
+async def _create(client, workspace_id: int) -> dict:
resp = await client.post(
BASE,
json={
"title": "Episode",
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"source_content": "Source content.",
},
)
@@ -28,20 +28,20 @@ async def _create(client, search_space_id: int) -> dict:
async def test_approve_brief_starts_drafting_and_enqueues_draft(
- client, db_search_space, captured_tasks
+ client, db_workspace, captured_tasks
):
- podcast = await _create(client, db_search_space.id)
+ podcast = await _create(client, db_workspace.id)
resp = await client.post(f"{BASE}/{podcast['id']}/brief/approve")
assert resp.status_code == 200
assert resp.json()["status"] == "drafting"
- assert captured_tasks.draft == [((podcast["id"], db_search_space.id), {})]
+ assert captured_tasks.draft == [((podcast["id"], db_workspace.id), {})]
assert captured_tasks.render == []
-async def test_update_spec_bumps_version_and_persists(client, db_search_space):
- podcast = await _create(client, db_search_space.id)
+async def test_update_spec_bumps_version_and_persists(client, db_workspace):
+ podcast = await _create(client, db_workspace.id)
spec = podcast["spec"]
spec["focus"] = "A sharper angle"
@@ -57,8 +57,8 @@ async def test_update_spec_bumps_version_and_persists(client, db_search_space):
assert body["status"] == "awaiting_brief"
-async def test_update_spec_with_stale_version_conflicts(client, db_search_space):
- podcast = await _create(client, db_search_space.id)
+async def test_update_spec_with_stale_version_conflicts(client, db_workspace):
+ podcast = await _create(client, db_workspace.id)
resp = await client.patch(
f"{BASE}/{podcast['id']}/spec",
@@ -68,8 +68,8 @@ async def test_update_spec_with_stale_version_conflicts(client, db_search_space)
assert resp.status_code == 409
-async def test_update_spec_after_approval_is_rejected(client, db_search_space):
- podcast = await _create(client, db_search_space.id)
+async def test_update_spec_after_approval_is_rejected(client, db_workspace):
+ podcast = await _create(client, db_workspace.id)
await client.post(f"{BASE}/{podcast['id']}/brief/approve")
resp = await client.patch(
diff --git a/surfsense_backend/tests/integration/podcasts/test_cancel.py b/surfsense_backend/tests/integration/podcasts/test_cancel.py
index 4fe4cfc55..33daeef28 100644
--- a/surfsense_backend/tests/integration/podcasts/test_cancel.py
+++ b/surfsense_backend/tests/integration/podcasts/test_cancel.py
@@ -15,9 +15,9 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
-async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_podcast):
+async def test_cancel_from_a_live_state_succeeds(client, db_workspace, make_podcast):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
+ workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
)
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
@@ -27,10 +27,10 @@ async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_p
async def test_cancel_from_a_terminal_state_conflicts(
- client, db_search_space, make_podcast
+ client, db_workspace, make_podcast
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
@@ -38,13 +38,11 @@ async def test_cancel_from_a_terminal_state_conflicts(
assert resp.status_code == 409
-async def test_cancel_of_a_regeneration_is_rejected(
- client, db_search_space, make_podcast
-):
+async def test_cancel_of_a_regeneration_is_rejected(client, db_workspace, make_podcast):
# Cancelling here would destroy a playable episode; reverting the
# regeneration is the way back.
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
diff --git a/surfsense_backend/tests/integration/podcasts/test_create.py b/surfsense_backend/tests/integration/podcasts/test_create.py
index 19b5aeca2..fcac1148d 100644
--- a/surfsense_backend/tests/integration/podcasts/test_create.py
+++ b/surfsense_backend/tests/integration/podcasts/test_create.py
@@ -14,12 +14,12 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
-async def test_create_proposes_brief_and_opens_gate(client, db_search_space):
+async def test_create_proposes_brief_and_opens_gate(client, db_workspace):
resp = await client.post(
BASE,
json={
"title": "My Episode",
- "search_space_id": db_search_space.id,
+ "workspace_id": db_workspace.id,
"source_content": "A long piece of source content about a topic.",
},
)
@@ -36,12 +36,12 @@ async def test_create_proposes_brief_and_opens_gate(client, db_search_space):
assert body["has_audio"] is False
-async def test_create_honors_requested_speaker_count(client, db_search_space):
+async def test_create_honors_requested_speaker_count(client, db_workspace):
resp = await client.post(
BASE,
json={
"title": "Solo",
- "search_space_id": db_search_space.id,
+ "workspace_id": db_workspace.id,
"source_content": "Content.",
"speaker_count": 3,
},
diff --git a/surfsense_backend/tests/integration/podcasts/test_draft_task.py b/surfsense_backend/tests/integration/podcasts/test_draft_task.py
index 014d98b1f..dd43b3656 100644
--- a/surfsense_backend/tests/integration/podcasts/test_draft_task.py
+++ b/surfsense_backend/tests/integration/podcasts/test_draft_task.py
@@ -33,22 +33,22 @@ pytestmark = pytest.mark.integration
def _wire_billing(monkeypatch, *, billable_call, transcript=None) -> None:
"""Replace the billing + LLM externals the draft body reaches for."""
- async def _resolver(_session, _search_space_id, *, thread_id=None):
+ async def _resolver(_session, _workspace_id, *, thread_id=None):
return uuid4(), "free", "openrouter/model"
async def _ainvoke(_state, config=None):
return {"transcript": transcript}
- monkeypatch.setattr(draft, "_resolve_agent_billing_for_search_space", _resolver)
+ monkeypatch.setattr(draft, "_resolve_agent_billing_for_workspace", _resolver)
monkeypatch.setattr(draft, "billable_call", billable_call)
monkeypatch.setattr(draft, "transcript_graph", SimpleNamespace(ainvoke=_ainvoke))
async def test_successful_draft_stores_transcript_and_starts_rendering(
- monkeypatch, db_search_space, make_podcast, bind_task_session, captured_tasks
+ monkeypatch, db_workspace, make_podcast, bind_task_session, captured_tasks
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
+ workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
)
@asynccontextmanager
@@ -57,7 +57,7 @@ async def test_successful_draft_stores_transcript_and_starts_rendering(
_wire_billing(monkeypatch, billable_call=_ok, transcript=build_transcript())
- result = await draft._draft_transcript(podcast.id, db_search_space.id)
+ result = await draft._draft_transcript(podcast.id, db_workspace.id)
assert result["status"] == "rendering"
assert podcast.status == PodcastStatus.RENDERING
@@ -66,10 +66,10 @@ async def test_successful_draft_stores_transcript_and_starts_rendering(
async def test_quota_denial_fails_the_podcast_without_a_transcript(
- monkeypatch, db_search_space, make_podcast, bind_task_session
+ monkeypatch, db_workspace, make_podcast, bind_task_session
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
+ workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
)
@asynccontextmanager
@@ -83,7 +83,7 @@ async def test_quota_denial_fails_the_podcast_without_a_transcript(
_wire_billing(monkeypatch, billable_call=_deny)
- result = await draft._draft_transcript(podcast.id, db_search_space.id)
+ result = await draft._draft_transcript(podcast.id, db_workspace.id)
assert result["reason"] == "quota"
assert podcast.status == PodcastStatus.FAILED
@@ -91,10 +91,10 @@ async def test_quota_denial_fails_the_podcast_without_a_transcript(
async def test_billing_settlement_failure_fails_the_podcast(
- monkeypatch, db_search_space, make_podcast, bind_task_session
+ monkeypatch, db_workspace, make_podcast, bind_task_session
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
+ workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
)
@asynccontextmanager
@@ -110,7 +110,7 @@ async def test_billing_settlement_failure_fails_the_podcast(
monkeypatch, billable_call=_settlement_fails, transcript=build_transcript()
)
- result = await draft._draft_transcript(podcast.id, db_search_space.id)
+ result = await draft._draft_transcript(podcast.id, db_workspace.id)
assert result["reason"] == "billing"
assert podcast.status == PodcastStatus.FAILED
diff --git a/surfsense_backend/tests/integration/podcasts/test_public_stream.py b/surfsense_backend/tests/integration/podcasts/test_public_stream.py
index 63f634234..db08cc828 100644
--- a/surfsense_backend/tests/integration/podcasts/test_public_stream.py
+++ b/surfsense_backend/tests/integration/podcasts/test_public_stream.py
@@ -12,9 +12,9 @@ from app.db import NewChatThread, PublicChatSnapshot, User
pytestmark = pytest.mark.integration
-async def _snapshot(db_session, *, search_space_id, user: User, token: str, podcasts):
+async def _snapshot(db_session, *, workspace_id, user: User, token: str, podcasts):
thread = NewChatThread(
- title="Shared", search_space_id=search_space_id, created_by_id=user.id
+ title="Shared", workspace_id=workspace_id, created_by_id=user.id
)
db_session.add(thread)
await db_session.flush()
@@ -30,11 +30,11 @@ async def _snapshot(db_session, *, search_space_id, user: User, token: str, podc
async def test_public_stream_serves_audio_via_storage_key(
- client, db_session, db_search_space, db_user, fake_storage
+ client, db_session, db_workspace, db_user, fake_storage
):
await _snapshot(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user=db_user,
token="tok-audio",
podcasts=[{"original_id": 555, "storage_key": "podcasts/x.mp3"}],
@@ -49,11 +49,11 @@ async def test_public_stream_serves_audio_via_storage_key(
async def test_public_stream_404_when_object_missing(
- client, db_session, db_search_space, db_user, fake_storage
+ client, db_session, db_workspace, db_user, fake_storage
):
await _snapshot(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user=db_user,
token="tok-gone",
podcasts=[{"original_id": 556, "storage_key": "podcasts/gone.mp3"}],
@@ -65,11 +65,11 @@ async def test_public_stream_404_when_object_missing(
async def test_public_stream_404_when_podcast_absent_from_snapshot(
- client, db_session, db_search_space, db_user
+ client, db_session, db_workspace, db_user
):
await _snapshot(
db_session,
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
user=db_user,
token="tok-empty",
podcasts=[],
diff --git a/surfsense_backend/tests/integration/podcasts/test_regeneration.py b/surfsense_backend/tests/integration/podcasts/test_regeneration.py
index fd31df4ca..c93bf8dfd 100644
--- a/surfsense_backend/tests/integration/podcasts/test_regeneration.py
+++ b/surfsense_backend/tests/integration/podcasts/test_regeneration.py
@@ -24,10 +24,10 @@ BASE = "/api/v1/podcasts"
async def test_regenerate_from_ready_reopens_the_brief_gate(
- client, db_search_space, make_podcast, captured_tasks
+ client, db_workspace, make_podcast, captured_tasks
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
@@ -43,10 +43,10 @@ async def test_regenerate_from_ready_reopens_the_brief_gate(
async def test_approving_the_reopened_brief_starts_a_fresh_draft(
- client, db_search_space, make_podcast, captured_tasks
+ client, db_workspace, make_podcast, captured_tasks
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
@@ -54,15 +54,15 @@ async def test_approving_the_reopened_brief_starts_a_fresh_draft(
assert resp.status_code == 200
assert resp.json()["status"] == "drafting"
- assert captured_tasks.draft == [((podcast.id, db_search_space.id), {})]
+ assert captured_tasks.draft == [((podcast.id, db_workspace.id), {})]
async def test_regenerate_from_brief_gate_is_rejected(
- client, db_search_space, make_podcast, captured_tasks
+ client, db_workspace, make_podcast, captured_tasks
):
# Nothing has been drafted yet, so there is nothing to regenerate.
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
+ workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
@@ -72,10 +72,10 @@ async def test_regenerate_from_brief_gate_is_rejected(
async def test_regenerate_from_cancelled_is_rejected(
- client, db_search_space, make_podcast, captured_tasks
+ client, db_workspace, make_podcast, captured_tasks
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
+ workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
)
await client.post(f"{BASE}/{podcast.id}/cancel")
@@ -86,10 +86,10 @@ async def test_regenerate_from_cancelled_is_rejected(
async def test_reverting_a_regeneration_restores_the_ready_episode(
- client, db_search_space, make_podcast, captured_tasks
+ client, db_workspace, make_podcast, captured_tasks
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
@@ -105,12 +105,12 @@ async def test_reverting_a_regeneration_restores_the_ready_episode(
async def test_reverting_mid_draft_keeps_the_episode(
- client, db_search_space, make_podcast
+ client, db_workspace, make_podcast
):
# Changing one's mind is allowed even after the reopened brief was
# approved: the episode survives until a new render replaces it.
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
await client.post(f"{BASE}/{podcast.id}/brief/approve")
@@ -122,10 +122,10 @@ async def test_reverting_mid_draft_keeps_the_episode(
async def test_reverting_mid_render_keeps_the_episode(
- client, db_session, db_search_space, make_podcast
+ client, db_session, db_workspace, make_podcast
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
service = PodcastService(db_session)
await service.regenerate(podcast)
@@ -139,12 +139,12 @@ async def test_reverting_mid_render_keeps_the_episode(
async def test_reverted_episode_can_be_regenerated_again(
- client, db_search_space, make_podcast
+ client, db_workspace, make_podcast
):
# Reverting must not strand the episode: the user can change their mind
# again immediately.
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
@@ -156,11 +156,11 @@ async def test_reverted_episode_can_be_regenerated_again(
async def test_revert_on_a_fresh_brief_gate_is_rejected(
- client, db_search_space, make_podcast
+ client, db_workspace, make_podcast
):
# A first-time brief has no regeneration to revert.
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
+ workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
)
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
@@ -170,10 +170,10 @@ async def test_revert_on_a_fresh_brief_gate_is_rejected(
async def test_revert_when_nothing_was_regenerated_is_rejected(
- client, db_search_space, make_podcast
+ client, db_workspace, make_podcast
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
@@ -182,13 +182,13 @@ async def test_revert_when_nothing_was_regenerated_is_rejected(
async def test_regenerate_without_a_brief_is_rejected(
- client, db_session, db_search_space, captured_tasks
+ client, db_session, db_workspace, captured_tasks
):
# Legacy episodes finished before briefs existed; reopening a gate with
# nothing to review would strand them there.
podcast = Podcast(
title="Legacy Episode",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
status=PodcastStatus.READY,
spec_version=1,
file_location="/var/old/podcast.mp3",
diff --git a/surfsense_backend/tests/integration/podcasts/test_render_task.py b/surfsense_backend/tests/integration/podcasts/test_render_task.py
index 5a97a00c7..8c185e3c2 100644
--- a/surfsense_backend/tests/integration/podcasts/test_render_task.py
+++ b/surfsense_backend/tests/integration/podcasts/test_render_task.py
@@ -20,10 +20,10 @@ pytestmark = pytest.mark.integration
async def test_render_marks_ready_and_stores_audio(
- db_search_space, make_podcast, bind_task_session, fake_tts, fake_merge, fake_storage
+ db_workspace, make_podcast, bind_task_session, fake_tts, fake_merge, fake_storage
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.RENDERING
+ workspace_id=db_workspace.id, status=PodcastStatus.RENDERING
)
result = await render._render_audio(podcast.id)
@@ -37,7 +37,7 @@ async def test_render_marks_ready_and_stores_audio(
async def test_rerender_replaces_audio_and_purges_the_old_object(
db_session,
- db_search_space,
+ db_workspace,
make_podcast,
bind_task_session,
fake_tts,
@@ -47,7 +47,7 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
# A regenerated episode keeps exactly one stored object: the new render
# must not leak the superseded audio in the object store.
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
old_key = podcast.storage_key
fake_storage.objects[old_key] = b"old-audio"
@@ -68,7 +68,7 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothing(
db_session,
- db_search_space,
+ db_workspace,
make_podcast,
bind_task_session,
fake_tts,
@@ -79,7 +79,7 @@ async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothin
# stale render must neither resurrect the redo nor leak the object it
# already stored.
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
old_key = podcast.storage_key
fake_storage.objects[old_key] = b"old-audio"
diff --git a/surfsense_backend/tests/integration/podcasts/test_scoping.py b/surfsense_backend/tests/integration/podcasts/test_scoping.py
index 304af6b6e..d5d497091 100644
--- a/surfsense_backend/tests/integration/podcasts/test_scoping.py
+++ b/surfsense_backend/tests/integration/podcasts/test_scoping.py
@@ -1,4 +1,4 @@
-"""Podcasts are scoped to search-space membership.
+"""Podcasts are scoped to workspace membership.
A user can only create or read podcasts in spaces they belong to, and an
unscoped listing returns only the caller's own podcasts — never another
@@ -13,9 +13,9 @@ BASE = "/api/v1/podcasts"
async def test_reading_a_podcast_in_a_nonmember_space_is_forbidden(
- client, db_search_space, make_podcast, act_as, db_other_user
+ client, db_workspace, make_podcast, act_as, db_other_user
):
- podcast = await make_podcast(search_space_id=db_search_space.id)
+ podcast = await make_podcast(workspace_id=db_workspace.id)
act_as(db_other_user)
resp = await client.get(f"{BASE}/{podcast.id}")
@@ -24,7 +24,7 @@ async def test_reading_a_podcast_in_a_nonmember_space_is_forbidden(
async def test_creating_in_a_nonmember_space_is_forbidden(
- client, db_search_space, act_as, db_other_user
+ client, db_workspace, act_as, db_other_user
):
act_as(db_other_user)
@@ -32,7 +32,7 @@ async def test_creating_in_a_nonmember_space_is_forbidden(
BASE,
json={
"title": "X",
- "search_space_id": db_search_space.id,
+ "workspace_id": db_workspace.id,
"source_content": "content",
},
)
@@ -41,9 +41,9 @@ async def test_creating_in_a_nonmember_space_is_forbidden(
async def test_listing_returns_only_the_callers_podcasts(
- client, db_search_space, make_podcast, foreign_podcast
+ client, db_workspace, make_podcast, foreign_podcast
):
- mine = await make_podcast(search_space_id=db_search_space.id, title="Mine")
+ mine = await make_podcast(workspace_id=db_workspace.id, title="Mine")
resp = await client.get(BASE)
diff --git a/surfsense_backend/tests/integration/podcasts/test_streaming.py b/surfsense_backend/tests/integration/podcasts/test_streaming.py
index b924e2971..b8d748c07 100644
--- a/surfsense_backend/tests/integration/podcasts/test_streaming.py
+++ b/surfsense_backend/tests/integration/podcasts/test_streaming.py
@@ -16,10 +16,10 @@ BASE = "/api/v1/podcasts"
async def test_stream_serves_stored_audio(
- client, db_search_space, make_podcast, fake_storage
+ client, db_workspace, make_podcast, fake_storage
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
fake_storage.objects["podcasts/audio.mp3"] = b"the-audio"
@@ -30,9 +30,9 @@ async def test_stream_serves_stored_audio(
assert resp.content == b"the-audio"
-async def test_stream_409_while_in_flight(client, db_search_space, make_podcast):
+async def test_stream_409_while_in_flight(client, db_workspace, make_podcast):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
+ workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
)
resp = await client.get(f"{BASE}/{podcast.id}/stream")
@@ -41,10 +41,10 @@ async def test_stream_409_while_in_flight(client, db_search_space, make_podcast)
async def test_stream_404_when_object_missing(
- client, db_search_space, make_podcast, fake_storage
+ client, db_workspace, make_podcast, fake_storage
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
resp = await client.get(f"{BASE}/{podcast.id}/stream")
diff --git a/surfsense_backend/tests/integration/podcasts/test_task_failure.py b/surfsense_backend/tests/integration/podcasts/test_task_failure.py
index 43212f58f..8b07f4753 100644
--- a/surfsense_backend/tests/integration/podcasts/test_task_failure.py
+++ b/surfsense_backend/tests/integration/podcasts/test_task_failure.py
@@ -17,10 +17,10 @@ pytestmark = pytest.mark.integration
async def test_marking_failed_records_the_reason_on_a_running_podcast(
- db_search_space, make_podcast, bind_task_session
+ db_workspace, make_podcast, bind_task_session
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
+ workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
)
await runtime.mark_failed(podcast.id, "tts provider unavailable")
@@ -30,10 +30,10 @@ async def test_marking_failed_records_the_reason_on_a_running_podcast(
async def test_marking_failed_leaves_an_already_terminal_podcast_untouched(
- db_search_space, make_podcast, bind_task_session
+ db_workspace, make_podcast, bind_task_session
):
podcast = await make_podcast(
- search_space_id=db_search_space.id, status=PodcastStatus.READY
+ workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await runtime.mark_failed(podcast.id, "too late")
diff --git a/surfsense_backend/tests/integration/retriever/conftest.py b/surfsense_backend/tests/integration/retriever/conftest.py
index d2443723c..096b5c0dd 100644
--- a/surfsense_backend/tests/integration/retriever/conftest.py
+++ b/surfsense_backend/tests/integration/retriever/conftest.py
@@ -9,7 +9,7 @@ import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config as app_config
-from app.db import Chunk, Document, DocumentType, SearchSpace, User
+from app.db import Chunk, Document, DocumentType, User, Workspace
EMBEDDING_DIM = app_config.embedding_model_instance.dimension
DUMMY_EMBEDDING = [0.1] * EMBEDDING_DIM
@@ -20,7 +20,7 @@ def _make_document(
title: str,
document_type: DocumentType,
content: str,
- search_space_id: int,
+ workspace_id: int,
created_by_id: str,
updated_at: datetime | None = None,
) -> Document:
@@ -32,7 +32,7 @@ def _make_document(
content_hash=f"content-{uid}",
unique_identifier_hash=f"uid-{uid}",
source_markdown=content,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
created_by_id=created_by_id,
embedding=DUMMY_EMBEDDING,
updated_at=updated_at or datetime.now(UTC),
@@ -50,29 +50,29 @@ def _make_chunk(*, content: str, document_id: int) -> Chunk:
@pytest_asyncio.fixture
async def seed_large_doc(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Insert a document with 35 chunks (more than _MAX_FETCH_CHUNKS_PER_DOC=20).
Also inserts a small 3-chunk document for diversity testing.
- Returns a dict with ``large_doc``, ``small_doc``, ``search_space``, ``user``,
+ Returns a dict with ``large_doc``, ``small_doc``, ``workspace``, ``user``,
and ``large_chunk_ids`` (all 35 chunk IDs).
"""
user_id = str(db_user.id)
- space_id = db_search_space.id
+ space_id = db_workspace.id
large_doc = _make_document(
title="Large PDF Document",
document_type=DocumentType.FILE,
content="large document about quarterly performance reviews and budgets",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
)
small_doc = _make_document(
title="Small Note",
document_type=DocumentType.NOTE,
content="quarterly performance review summary note",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
)
@@ -102,25 +102,25 @@ async def seed_large_doc(
"small_doc": small_doc,
"large_chunk_ids": [c.id for c in large_chunks],
"small_chunk_ids": [c.id for c in small_chunks],
- "search_space": db_search_space,
+ "workspace": db_workspace,
"user": db_user,
}
@pytest_asyncio.fixture
async def seed_date_filtered_docs(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Insert matching docs with different timestamps for date-filter tests."""
user_id = str(db_user.id)
- space_id = db_search_space.id
+ space_id = db_workspace.id
now = datetime.now(UTC)
recent_doc = _make_document(
title="Recent OCV Notes",
document_type=DocumentType.FILE,
content="ocv meeting decisions and action items",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
updated_at=now,
)
@@ -128,7 +128,7 @@ async def seed_date_filtered_docs(
title="Old OCV Notes",
document_type=DocumentType.FILE,
content="ocv meeting decisions and action items",
- search_space_id=space_id,
+ workspace_id=space_id,
created_by_id=user_id,
updated_at=now - timedelta(days=730),
)
@@ -153,6 +153,6 @@ async def seed_date_filtered_docs(
return {
"recent_doc": recent_doc,
"old_doc": old_doc,
- "search_space": db_search_space,
+ "workspace": db_workspace,
"user": db_user,
}
diff --git a/surfsense_backend/tests/integration/retriever/test_optimized_chunk_retriever.py b/surfsense_backend/tests/integration/retriever/test_optimized_chunk_retriever.py
index f80e59304..78da3224e 100644
--- a/surfsense_backend/tests/integration/retriever/test_optimized_chunk_retriever.py
+++ b/surfsense_backend/tests/integration/retriever/test_optimized_chunk_retriever.py
@@ -18,13 +18,13 @@ pytestmark = pytest.mark.integration
async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
"""A document with 35 chunks should have at most _MAX_FETCH_CHUNKS_PER_DOC chunks returned."""
- space_id = seed_large_doc["search_space"].id
+ space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@@ -40,13 +40,13 @@ async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
async def test_doc_metadata_populated_from_rrf(db_session, seed_large_doc):
"""Document metadata (title, type, etc.) should be present even without joinedload."""
- space_id = seed_large_doc["search_space"].id
+ space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@@ -62,13 +62,13 @@ async def test_doc_metadata_populated_from_rrf(db_session, seed_large_doc):
async def test_matched_chunk_ids_tracked(db_session, seed_large_doc):
"""matched_chunk_ids should contain the chunk IDs that appeared in the RRF results."""
- space_id = seed_large_doc["search_space"].id
+ space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@@ -83,13 +83,13 @@ async def test_matched_chunk_ids_tracked(db_session, seed_large_doc):
async def test_chunks_ordered_by_id(db_session, seed_large_doc):
"""Chunks within each document should be ordered by chunk ID (original order)."""
- space_id = seed_large_doc["search_space"].id
+ space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@@ -100,13 +100,13 @@ async def test_chunks_ordered_by_id(db_session, seed_large_doc):
async def test_score_is_positive_float(db_session, seed_large_doc):
"""Each result should have a positive float score from RRF."""
- space_id = seed_large_doc["search_space"].id
+ space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
diff --git a/surfsense_backend/tests/integration/retriever/test_optimized_doc_retriever.py b/surfsense_backend/tests/integration/retriever/test_optimized_doc_retriever.py
index 435f1eebf..d43d76cce 100644
--- a/surfsense_backend/tests/integration/retriever/test_optimized_doc_retriever.py
+++ b/surfsense_backend/tests/integration/retriever/test_optimized_doc_retriever.py
@@ -17,13 +17,13 @@ pytestmark = pytest.mark.integration
async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
"""A document with 35 chunks should have at most _MAX_FETCH_CHUNKS_PER_DOC chunks returned."""
- space_id = seed_large_doc["search_space"].id
+ space_id = seed_large_doc["workspace"].id
retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@@ -39,13 +39,13 @@ async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
async def test_doc_metadata_populated(db_session, seed_large_doc):
"""Document metadata should be present from the RRF results."""
- space_id = seed_large_doc["search_space"].id
+ space_id = seed_large_doc["workspace"].id
retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@@ -61,13 +61,13 @@ async def test_doc_metadata_populated(db_session, seed_large_doc):
async def test_chunks_ordered_by_id(db_session, seed_large_doc):
"""Chunks within each document should be ordered by chunk ID."""
- space_id = seed_large_doc["search_space"].id
+ space_id = seed_large_doc["workspace"].id
retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
- search_space_id=space_id,
+ workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
diff --git a/surfsense_backend/tests/integration/test_connector_index_authz.py b/surfsense_backend/tests/integration/test_connector_index_authz.py
index b25df7087..61aba762d 100644
--- a/surfsense_backend/tests/integration/test_connector_index_authz.py
+++ b/surfsense_backend/tests/integration/test_connector_index_authz.py
@@ -1,13 +1,13 @@
-"""Cross-search-space authorization on the connector index endpoint.
+"""Cross-workspace authorization on the connector index endpoint.
-``POST /search-source-connectors/{connector_id}/index?search_space_id=`` must
-authorize against the **connector's own** ``search_space_id`` (matching the
-read/update/delete handlers), not the caller-supplied ``search_space_id`` query
+``POST /search-source-connectors/{connector_id}/index?workspace_id=`` must
+authorize against the **connector's own** ``workspace_id`` (matching the
+read/update/delete handlers), not the caller-supplied ``workspace_id`` query
parameter, and must reject a connector that does not belong to the requested
-search space.
+workspace.
-Without this, a user who owns search space B could index another user's
-connector (which lives in space A) by passing ``search_space_id=B``: the
+Without this, a user who owns workspace B could index another user's
+connector (which lives in space A) by passing ``workspace_id=B``: the
background indexer would run with the **victim connector's stored credentials**
and write the fetched content into the attacker's space. These tests pin that
boundary.
@@ -27,11 +27,11 @@ from app.auth.context import AuthContext
from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
- SearchSpace,
User,
+ Workspace,
)
from app.routes.search_source_connectors_routes import index_connector_content
-from app.routes.search_spaces_routes import create_default_roles_and_membership
+from app.routes.workspaces_routes import create_default_roles_and_membership
pytestmark = pytest.mark.integration
@@ -39,9 +39,9 @@ pytestmark = pytest.mark.integration
_CHECK_PERMISSION = "app.routes.search_source_connectors_routes.check_permission"
-async def _make_user_with_space(session: AsyncSession) -> tuple[User, SearchSpace]:
- """A user plus a search space they own, with the default roles/membership
- the ``POST /searchspaces`` route would create (so ``check_permission`` would
+async def _make_user_with_space(session: AsyncSession) -> tuple[User, Workspace]:
+ """A user plus a workspace they own, with the default roles/membership
+ the ``POST /workspaces`` route would create (so ``check_permission`` would
legitimately pass for this user on this space)."""
user = User(
id=uuid.uuid4(),
@@ -53,7 +53,7 @@ async def _make_user_with_space(session: AsyncSession) -> tuple[User, SearchSpac
)
session.add(user)
await session.flush()
- space = SearchSpace(name=f"Space {uuid.uuid4().hex[:8]}", user_id=user.id)
+ space = Workspace(name=f"Space {uuid.uuid4().hex[:8]}", user_id=user.id)
session.add(space)
await session.flush()
await create_default_roles_and_membership(session, space.id, user.id)
@@ -64,7 +64,7 @@ async def _make_user_with_space(session: AsyncSession) -> tuple[User, SearchSpac
async def _make_connector(
session: AsyncSession,
owner: User,
- space: SearchSpace,
+ space: Workspace,
connector_type: SearchSourceConnectorType,
) -> SearchSourceConnector:
connector = SearchSourceConnector(
@@ -77,7 +77,7 @@ async def _make_connector(
"repo_full_names": ["octocat/Hello-World"],
},
is_indexable=True,
- search_space_id=space.id,
+ workspace_id=space.id,
user_id=owner.id,
)
session.add(connector)
@@ -90,7 +90,7 @@ class TestConnectorIndexCrossSpaceAuthz:
self, db_session: AsyncSession
):
"""Attacker (owns space B) cannot index victim's connector (in space A)
- by passing ``search_space_id=B``.
+ by passing ``workspace_id=B``.
The mismatch is rejected with 404 **before** ``check_permission`` runs —
which is essential, because that permission check *would* pass: the
@@ -108,13 +108,13 @@ class TestConnectorIndexCrossSpaceAuthz:
):
await index_connector_content(
connector_id=connector_a.id,
- search_space_id=space_b.id, # the attacker's own space
+ workspace_id=space_b.id, # the attacker's own space
session=db_session,
auth=AuthContext.session(attacker),
)
assert exc_info.value.status_code == 404
- # Rejected at the search-space reconciliation, never reaching (or relying
+ # Rejected at the workspace reconciliation, never reaching (or relying
# on) the permission check — which would have passed for space B.
check_permission_mock.assert_not_awaited()
@@ -122,7 +122,7 @@ class TestConnectorIndexCrossSpaceAuthz:
self, db_session: AsyncSession
):
"""A legitimate same-space index passes the reconciliation and authorizes
- ``check_permission`` against the connector's **own** search space (not the
+ ``check_permission`` against the connector's **own** workspace (not the
client-supplied query param)."""
owner, space = await _make_user_with_space(db_session)
# A "live" connector type returns early (no Celery dispatch) right after
@@ -139,11 +139,11 @@ class TestConnectorIndexCrossSpaceAuthz:
):
await index_connector_content(
connector_id=connector.id,
- search_space_id=space.id, # the connector's own space
+ workspace_id=space.id, # the connector's own space
session=db_session,
auth=AuthContext.session(owner),
)
check_permission_mock.assert_awaited_once()
# The space passed to check_permission must be the connector's own space.
- assert connector.search_space_id in check_permission_mock.await_args.args
+ assert connector.workspace_id in check_permission_mock.await_args.args
diff --git a/surfsense_backend/tests/integration/test_document_versioning.py b/surfsense_backend/tests/integration/test_document_versioning.py
index 9bd03d219..64da21b1a 100644
--- a/surfsense_backend/tests/integration/test_document_versioning.py
+++ b/surfsense_backend/tests/integration/test_document_versioning.py
@@ -7,14 +7,14 @@ import pytest_asyncio
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db import Document, DocumentType, DocumentVersion, SearchSpace, User
+from app.db import Document, DocumentType, DocumentVersion, User, Workspace
pytestmark = pytest.mark.integration
@pytest_asyncio.fixture
async def db_document(
- db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> Document:
doc = Document(
title="Test Doc",
@@ -24,7 +24,7 @@ async def db_document(
content_hash="abc123",
unique_identifier_hash="local_folder:test-folder:test.md",
source_markdown="# Test\n\nOriginal content.",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
created_by_id=db_user.id,
)
db_session.add(doc)
diff --git a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py
index d56c18420..025bf2075 100644
--- a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py
+++ b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py
@@ -32,8 +32,8 @@ from app.auth.context import AuthContext
from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
- SearchSpace,
User,
+ Workspace,
)
from app.routes.obsidian_plugin_routes import (
obsidian_connect,
@@ -43,7 +43,7 @@ from app.routes.obsidian_plugin_routes import (
obsidian_stats,
obsidian_sync,
)
-from app.routes.search_spaces_routes import create_default_roles_and_membership
+from app.routes.workspaces_routes import create_default_roles_and_membership
from app.schemas.obsidian_plugin import (
ConnectRequest,
DeleteAck,
@@ -90,7 +90,7 @@ def _make_note_payload(vault_id: str, path: str, content_hash: str) -> NotePaylo
@pytest_asyncio.fixture
async def race_user_and_space(async_engine):
- """User + SearchSpace committed via the live engine so the two
+ """User + Workspace committed via the live engine so the two
concurrent /connect sessions in the race test can both see them.
We can't use the savepoint-trapped ``db_session`` fixture here
@@ -106,7 +106,7 @@ async def race_user_and_space(async_engine):
is_superuser=False,
is_verified=True,
)
- space = SearchSpace(name="Race Space", user_id=user_id)
+ space = Workspace(name="Race Space", user_id=user_id)
setup.add_all([user, space])
await setup.flush()
await create_default_roles_and_membership(setup, space.id, user_id)
@@ -125,15 +125,15 @@ async def race_user_and_space(async_engine):
{"uid": user_id},
)
await cleanup.execute(
- text("DELETE FROM search_space_memberships WHERE search_space_id = :id"),
+ text("DELETE FROM workspace_memberships WHERE workspace_id = :id"),
{"id": space_id},
)
await cleanup.execute(
- text("DELETE FROM search_space_roles WHERE search_space_id = :id"),
+ text("DELETE FROM workspace_roles WHERE workspace_id = :id"),
{"id": space_id},
)
await cleanup.execute(
- text("DELETE FROM searchspaces WHERE id = :id"),
+ text("DELETE FROM workspaces WHERE id = :id"),
{"id": space_id},
)
await cleanup.execute(
@@ -167,7 +167,7 @@ class TestConnectRace:
payload = ConnectRequest(
vault_id=vault_id,
vault_name=f"My Vault {name_suffix}",
- search_space_id=space_id,
+ workspace_id=space_id,
vault_fingerprint=fingerprint,
)
await obsidian_connect(payload, auth=_auth(fresh_user), session=s)
@@ -207,7 +207,7 @@ class TestConnectRace:
"vault_fingerprint": "fp-1",
},
user_id=user_id,
- search_space_id=space_id,
+ workspace_id=space_id,
)
)
await s.commit()
@@ -226,7 +226,7 @@ class TestConnectRace:
"vault_fingerprint": "fp-2",
},
user_id=user_id,
- search_space_id=space_id,
+ workspace_id=space_id,
)
)
await s.commit()
@@ -252,7 +252,7 @@ class TestConnectRace:
"vault_fingerprint": fingerprint,
},
user_id=user_id,
- search_space_id=space_id,
+ workspace_id=space_id,
)
)
await s.commit()
@@ -271,7 +271,7 @@ class TestConnectRace:
"vault_fingerprint": fingerprint,
},
user_id=user_id,
- search_space_id=space_id,
+ workspace_id=space_id,
)
)
await s.commit()
@@ -294,7 +294,7 @@ class TestConnectRace:
ConnectRequest(
vault_id=vault_id_a,
vault_name="Shared Vault",
- search_space_id=space_id,
+ workspace_id=space_id,
vault_fingerprint=fingerprint,
),
auth=_auth(fresh_user),
@@ -307,7 +307,7 @@ class TestConnectRace:
ConnectRequest(
vault_id=vault_id_b,
vault_name="Shared Vault",
- search_space_id=space_id,
+ workspace_id=space_id,
vault_fingerprint=fingerprint,
),
auth=_auth(fresh_user),
@@ -341,7 +341,7 @@ class TestWireContractSmoke:
field renames the way the TypeScript decoder would catch them."""
async def test_full_flow_returns_typed_payloads(
- self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ self, db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
vault_id = str(uuid.uuid4())
@@ -350,7 +350,7 @@ class TestWireContractSmoke:
ConnectRequest(
vault_id=vault_id,
vault_name="Smoke Vault",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
vault_fingerprint="fp-" + uuid.uuid4().hex,
),
auth=_auth(db_user),
@@ -488,14 +488,14 @@ class TestWireContractSmoke:
assert stats_resp.last_sync_at is None
async def test_sync_queues_binary_attachments(
- self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ self, db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
vault_id = str(uuid.uuid4())
await obsidian_connect(
ConnectRequest(
vault_id=vault_id,
vault_name="Queue Vault",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
vault_fingerprint="fp-" + uuid.uuid4().hex,
),
auth=_auth(db_user),
@@ -539,14 +539,14 @@ class TestWireContractSmoke:
queue_mock.assert_called_once()
async def test_sync_rejects_unsupported_attachment_extension(
- self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ self, db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
vault_id = str(uuid.uuid4())
await obsidian_connect(
ConnectRequest(
vault_id=vault_id,
vault_name="Reject Vault",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
vault_fingerprint="fp-" + uuid.uuid4().hex,
),
auth=_auth(db_user),
@@ -593,14 +593,14 @@ class TestWireContractSmoke:
queue_mock.assert_not_called()
async def test_sync_rejects_mime_extension_mismatch(
- self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
+ self, db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
vault_id = str(uuid.uuid4())
await obsidian_connect(
ConnectRequest(
vault_id=vault_id,
vault_name="Mismatch Vault",
- search_space_id=db_search_space.id,
+ workspace_id=db_workspace.id,
vault_fingerprint="fp-" + uuid.uuid4().hex,
),
auth=_auth(db_user),
diff --git a/surfsense_backend/tests/integration/test_pat_fail_closed_authz.py b/surfsense_backend/tests/integration/test_pat_fail_closed_authz.py
index 5bec3f48a..a398029d0 100644
--- a/surfsense_backend/tests/integration/test_pat_fail_closed_authz.py
+++ b/surfsense_backend/tests/integration/test_pat_fail_closed_authz.py
@@ -7,9 +7,9 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
-from app.db import PersonalAccessToken, SearchSpace, User
+from app.db import PersonalAccessToken, User, Workspace
from app.users import allow_any_principal, require_session_context
-from app.utils.rbac import check_search_space_access
+from app.utils.rbac import check_workspace_access
pytestmark = pytest.mark.integration
@@ -43,29 +43,29 @@ async def test_pat_is_allowed_by_bootstrap_dependency(db_user: User):
async def test_pat_is_rejected_for_api_disabled_space(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- db_search_space.api_access_enabled = False
+ db_workspace.api_access_enabled = False
await db_session.flush()
auth = _pat_auth(db_user)
with pytest.raises(HTTPException) as exc_info:
- await check_search_space_access(db_session, auth, db_search_space.id)
+ await check_workspace_access(db_session, auth, db_workspace.id)
assert exc_info.value.status_code == 403
- assert exc_info.value.detail == "API access is not enabled for this search space."
+ assert exc_info.value.detail == "API access is not enabled for this workspace."
async def test_pat_is_allowed_for_api_enabled_space(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- db_search_space.api_access_enabled = True
+ db_workspace.api_access_enabled = True
await db_session.flush()
auth = _pat_auth(db_user)
- membership = await check_search_space_access(db_session, auth, db_search_space.id)
+ membership = await check_workspace_access(db_session, auth, db_workspace.id)
assert membership.user_id == db_user.id
- assert membership.search_space_id == db_search_space.id
+ assert membership.workspace_id == db_workspace.id
diff --git a/surfsense_backend/tests/integration/test_zero_authz_context.py b/surfsense_backend/tests/integration/test_zero_authz_context.py
index dcb0fe34a..a2d042a9d 100644
--- a/surfsense_backend/tests/integration/test_zero_authz_context.py
+++ b/surfsense_backend/tests/integration/test_zero_authz_context.py
@@ -7,9 +7,9 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
-from app.db import PersonalAccessToken, SearchSpace, User
-from app.routes.search_spaces_routes import create_default_roles_and_membership
-from app.utils.rbac import check_search_space_access, get_allowed_read_space_ids
+from app.db import PersonalAccessToken, User, Workspace
+from app.routes.workspaces_routes import create_default_roles_and_membership
+from app.utils.rbac import check_workspace_access, get_allowed_read_space_ids
pytestmark = pytest.mark.integration
@@ -30,8 +30,8 @@ async def _space_with_membership(
user: User,
*,
api_access_enabled: bool,
-) -> SearchSpace:
- space = SearchSpace(
+) -> Workspace:
+ space = Workspace(
name="Zero Authz Space",
user_id=user.id,
api_access_enabled=api_access_enabled,
@@ -43,10 +43,10 @@ async def _space_with_membership(
return space
-async def test_zero_read_set_matches_session_search_space_access(
+async def test_zero_read_set_matches_session_workspace_access(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
disabled_space = await _space_with_membership(
db_session,
@@ -57,17 +57,17 @@ async def test_zero_read_set_matches_session_search_space_access(
allowed_ids = set(await get_allowed_read_space_ids(db_session, session_auth))
- for space in (db_search_space, disabled_space):
- membership = await check_search_space_access(db_session, session_auth, space.id)
- assert membership.search_space_id in allowed_ids
+ for space in (db_workspace, disabled_space):
+ membership = await check_workspace_access(db_session, session_auth, space.id)
+ assert membership.workspace_id in allowed_ids
async def test_zero_read_set_applies_pat_api_access_gate(
db_session: AsyncSession,
db_user: User,
- db_search_space: SearchSpace,
+ db_workspace: Workspace,
):
- db_search_space.api_access_enabled = True
+ db_workspace.api_access_enabled = True
disabled_space = await _space_with_membership(
db_session,
db_user,
@@ -78,8 +78,8 @@ async def test_zero_read_set_applies_pat_api_access_gate(
allowed_ids = set(await get_allowed_read_space_ids(db_session, pat_auth))
- assert db_search_space.id in allowed_ids
+ assert db_workspace.id in allowed_ids
assert disabled_space.id not in allowed_ids
with pytest.raises(HTTPException) as exc_info:
- await check_search_space_access(db_session, pat_auth, disabled_space.id)
+ await check_workspace_access(db_session, pat_auth, disabled_space.id)
assert exc_info.value.status_code == 403
diff --git a/surfsense_backend/tests/unit/agents/chat/shared/tools/test_web_search.py b/surfsense_backend/tests/unit/agents/chat/shared/tools/test_web_search.py
deleted file mode 100644
index 7137bfdfc..000000000
--- a/surfsense_backend/tests/unit/agents/chat/shared/tools/test_web_search.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Tests for the shared ``web_search`` tool's citable-result adaptation.
-
-The tool's network path (SearXNG + live connectors) is out of scope here; these
-cover the pure mapping from raw web results to renderable, citable documents and
-the end-to-end registration of ``WEB_RESULT`` ``[n]`` labels.
-"""
-
-from __future__ import annotations
-
-import pytest
-
-from app.agents.chat.multi_agent_chat.shared.citations import (
- CitationRegistry,
- CitationSourceType,
-)
-from app.agents.chat.multi_agent_chat.shared.document_render import render_web_results
-from app.agents.chat.shared.tools.web_search import (
- _to_renderable_web_documents,
- _web_source_label,
-)
-
-pytestmark = pytest.mark.unit
-
-
-def _raw_result(url: str, title: str, content: str) -> dict:
- return {
- "document": {"title": title, "metadata": {"url": url}},
- "content": content,
- }
-
-
-def test_web_source_label_strips_scheme_and_www() -> None:
- assert _web_source_label("https://www.example.com/path") == "Web · example.com"
- assert _web_source_label("http://news.site.org/a/b") == "Web · news.site.org"
- assert _web_source_label("") == "Web"
-
-
-def test_adapter_maps_each_result_to_one_web_passage() -> None:
- docs = _to_renderable_web_documents(
- [
- _raw_result("https://a.com/x", "Alpha", "alpha body"),
- _raw_result("https://b.com/y", "Beta", "beta body"),
- ]
- )
-
- assert [d.title for d in docs] == ["Alpha", "Beta"]
- passages = [p for d in docs for p in d.passages]
- assert all(p.source_type is CitationSourceType.WEB_RESULT for p in passages)
- assert passages[0].locator == {"url": "https://a.com/x"}
- assert passages[0].content == "alpha body"
-
-
-def test_adapter_skips_results_without_url_or_content() -> None:
- docs = _to_renderable_web_documents(
- [
- _raw_result("", "No URL", "has content"),
- _raw_result("https://c.com/z", "Empty", " "),
- _raw_result("https://d.com/w", "Good", "real content"),
- ]
- )
-
- assert [d.title for d in docs] == ["Good"]
-
-
-def test_adapter_truncates_on_char_budget() -> None:
- big = "x" * 30
- docs = _to_renderable_web_documents(
- [
- _raw_result("https://a.com", "A", big),
- _raw_result("https://b.com", "B", big),
- _raw_result("https://c.com", "C", big),
- ],
- max_chars=50,
- )
-
- # First fits (30), second crosses 50 and stops the loop.
- assert [d.title for d in docs] == ["A"]
-
-
-def test_end_to_end_registers_web_results_for_citation() -> None:
- registry = CitationRegistry()
- docs = _to_renderable_web_documents(
- [_raw_result("https://example.com/a", "Example", "the answer is 42")]
- )
-
- block = render_web_results(docs, registry)
-
- assert block is not None
- assert "[1] the answer is 42" in block
- entry = registry.resolve(1)
- assert entry is not None
- assert entry.source_type is CitationSourceType.WEB_RESULT
- assert entry.locator == {"url": "https://example.com/a"}
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/document_render/test_web_results.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/document_render/test_web_results.py
deleted file mode 100644
index f96473667..000000000
--- a/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/document_render/test_web_results.py
+++ /dev/null
@@ -1,84 +0,0 @@
-"""Tests for the ```` wrapper around web-result excerpt documents."""
-
-from __future__ import annotations
-
-import pytest
-
-from app.agents.chat.multi_agent_chat.shared.citations import (
- CitationRegistry,
- CitationSourceType,
-)
-from app.agents.chat.multi_agent_chat.shared.document_render import (
- RenderableDocument,
- RenderablePassage,
- render_web_results,
-)
-
-pytestmark = pytest.mark.unit
-
-
-def _web_doc(url: str, title: str, content: str) -> RenderableDocument:
- return RenderableDocument(
- title=title,
- source=f"Web · {url.split('//', 1)[-1].split('/', 1)[0]}",
- passages=[
- RenderablePassage(
- content=content,
- locator={"url": url},
- source_type=CitationSourceType.WEB_RESULT,
- )
- ],
- )
-
-
-def test_returns_none_when_nothing_to_show() -> None:
- registry = CitationRegistry()
-
- assert render_web_results([], registry) is None
-
-
-def test_wraps_in_web_results_container() -> None:
- registry = CitationRegistry()
-
- block = render_web_results(
- [_web_doc("https://example.com/a", "Example", "the answer is 42")],
- registry,
- )
-
- assert block is not None
- assert block.startswith("")
- assert block.endswith(" ")
- assert "cite a result with its [n]" in block
- assert (
- '' in block
- )
- assert "[1] the answer is 42" in block
-
-
-def test_registers_each_result_as_web_result_with_url_locator() -> None:
- registry = CitationRegistry()
-
- render_web_results(
- [
- _web_doc("https://a.com/x", "A", "alpha"),
- _web_doc("https://b.com/y", "B", "beta"),
- ],
- registry,
- )
-
- first = registry.resolve(1)
- second = registry.resolve(2)
- assert first is not None and second is not None
- assert first.source_type is CitationSourceType.WEB_RESULT
- assert first.locator == {"url": "https://a.com/x"}
- assert second.locator == {"url": "https://b.com/y"}
-
-
-def test_same_url_reuses_label_across_calls() -> None:
- registry = CitationRegistry()
- doc = _web_doc("https://example.com/a", "Example", "stable fact")
-
- render_web_results([doc], registry)
- render_web_results([doc], registry)
-
- assert registry.next_n == 2
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py
index 2f3553a27..fa5faea5c 100644
--- a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py
@@ -25,6 +25,7 @@ from app.agents.chat.multi_agent_chat.shared.permissions.middleware.core import
PermissionMiddleware,
)
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
+ append_today_utc,
pack_subagent,
)
@@ -104,6 +105,35 @@ async def test_subagent_recovers_when_primary_llm_fails():
assert final.content == "recovered via fallback"
+def test_packed_subagent_prompt_carries_todays_utc_date():
+ """Subagents must inherit the main agent's clock, not guess the date."""
+ from datetime import UTC, datetime
+
+ today = datetime.now(UTC).date().isoformat()
+
+ result = pack_subagent(
+ name="date_test",
+ description="test",
+ system_prompt="be helpful",
+ tools=[],
+ ruleset=Ruleset(origin="date_test", rules=[]),
+ dependencies={"flags": AgentFeatureFlags()},
+ )
+
+ prompt = result.spec["system_prompt"]
+ assert prompt.startswith("be helpful")
+ assert f"Today (UTC): {today}" in prompt
+
+
+def test_append_today_utc_is_idempotent_shape():
+ """Helper appends exactly one dated line and preserves the original body."""
+ from datetime import UTC, datetime
+
+ today = datetime.now(UTC).date().isoformat()
+ out = append_today_utc("body")
+ assert out == f"body\n\nToday (UTC): {today}\n"
+
+
def _extract_permission_mw(spec) -> PermissionMiddleware:
"""Find the lone PermissionMiddleware in a subagent's middleware list."""
matches = [m for m in spec["middleware"] if isinstance(m, PermissionMiddleware)]
@@ -191,25 +221,25 @@ def test_missing_user_allowlist_keeps_coded_behaviour():
def test_user_allowlist_for_different_subagent_does_not_leak():
- """User trust for ``linear`` must not affect a ``jira`` subagent compile."""
+ """User trust for one subagent must not affect a different subagent compile."""
coded = Ruleset(
- origin="jira",
+ origin="mcp_discovery",
rules=[Rule(permission="save_issue", pattern="*", action="ask")],
)
- linear_allowlist = Ruleset(
- origin="user_allowlist:linear",
+ other_allowlist = Ruleset(
+ origin="user_allowlist:knowledge_base",
rules=[Rule(permission="save_issue", pattern="*", action="allow")],
)
result = pack_subagent(
- name="jira",
+ name="mcp_discovery",
description="test",
system_prompt="x",
tools=[],
ruleset=coded,
dependencies={
"flags": AgentFeatureFlags(),
- "user_allowlist_by_subagent": {"linear": linear_allowlist},
+ "user_allowlist_by_subagent": {"knowledge_base": other_allowlist},
},
)
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_mcp_allowlist_fallback.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_mcp_allowlist_fallback.py
new file mode 100644
index 000000000..eb6a03ccb
--- /dev/null
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_mcp_allowlist_fallback.py
@@ -0,0 +1,52 @@
+"""Allowlist filtering in ``_load_http_mcp_tools``.
+
+Covers the fallback added after Notion renamed its MCP tools ("notion-"
+prefix) and a stale allowlist silently disabled the connector: when the
+allowlist matches zero advertised tools, load everything (HITL-gated)
+instead of nothing.
+"""
+
+from datetime import UTC, datetime
+
+from app.agents.chat.multi_agent_chat.shared.tools.mcp.cache import (
+ CachedMCPTools,
+)
+from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
+ _load_http_mcp_tools,
+)
+
+_CACHED = CachedMCPTools(
+ discovered_at=datetime.now(UTC),
+ tools=[
+ {"name": "notion-search", "description": "search", "input_schema": {}},
+ {"name": "notion-update-page", "description": "write", "input_schema": {}},
+ ],
+)
+_SERVER_CONFIG = {"url": "https://example.com/mcp", "headers": {}}
+
+
+async def test_allowlist_match_filters_and_flags_readonly():
+ tools = await _load_http_mcp_tools(
+ 1,
+ "Notion",
+ _SERVER_CONFIG,
+ allowed_tools=["notion-search"],
+ readonly_tools=frozenset({"notion-search"}),
+ cached_tools=_CACHED,
+ )
+ assert [t.name for t in tools] == ["notion-search"]
+ assert tools[0].metadata["hitl"] is False
+
+
+async def test_stale_allowlist_falls_back_to_all_tools_hitl_gated():
+ tools = await _load_http_mcp_tools(
+ 1,
+ "Notion",
+ _SERVER_CONFIG,
+ allowed_tools=["search", "update-page"], # server renamed everything
+ readonly_tools=frozenset({"search"}),
+ cached_tools=_CACHED,
+ )
+ assert sorted(t.name for t in tools) == ["notion-search", "notion-update-page"]
+ # Renamed tools match no readonly entry -> every tool requires approval.
+ assert all(t.metadata["hitl"] is True for t in tools)
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_mcp_discovery_migration.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_mcp_discovery_migration.py
new file mode 100644
index 000000000..428b2f24f
--- /dev/null
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_mcp_discovery_migration.py
@@ -0,0 +1,162 @@
+"""Guardrails for the MCP-consolidation migration.
+
+Every MCP-backed connector now routes to the single ``mcp_discovery`` subagent
+(file connectors stay native; Discord/Teams/Luma are deprecated). These tests
+pin the pieces that make that safe: connector→route mapping, any-of gating,
+legacy checkpoint aliasing, tool-name collision prefixing, the metadata-derived
+approval ruleset, and the KB indexing-deprecation sets.
+"""
+
+from __future__ import annotations
+
+import pytest
+from langchain_core.tools import StructuredTool
+
+from app.agents.chat.multi_agent_chat.constants import (
+ CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS,
+ LEGACY_SUBAGENT_ALIASES,
+ SUBAGENT_TO_REQUIRED_CONNECTOR_MAP,
+)
+from app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.index import (
+ NAME as MCP_DISCOVERY_NAME,
+ build_ruleset,
+)
+from app.agents.chat.multi_agent_chat.subagents.mcp_tools.index import (
+ resolve_tool_name_collisions,
+)
+from app.agents.chat.multi_agent_chat.subagents.registry import (
+ SUBAGENT_BUILDERS_BY_NAME,
+ get_subagents_to_exclude,
+)
+from app.services.mcp_oauth.registry import (
+ DEPRECATED_INDEXING_CONNECTOR_TYPES,
+ LIVE_CONNECTOR_TYPES,
+)
+
+pytestmark = pytest.mark.unit
+
+# Connectors that must all funnel into ``mcp_discovery`` (not their own routes).
+_MCP_ROUTED = {
+ "SLACK_CONNECTOR",
+ "JIRA_CONNECTOR",
+ "LINEAR_CONNECTOR",
+ "CLICKUP_CONNECTOR",
+ "AIRTABLE_CONNECTOR",
+ "NOTION_CONNECTOR",
+ "CONFLUENCE_CONNECTOR",
+ "GOOGLE_GMAIL_CONNECTOR",
+ "GOOGLE_CALENDAR_CONNECTOR",
+ "MCP_CONNECTOR",
+}
+
+
+def _tool(name: str, metadata: dict) -> StructuredTool:
+ return StructuredTool.from_function(
+ func=lambda: name,
+ name=name,
+ description=name,
+ metadata=metadata,
+ )
+
+
+def test_all_mcp_connectors_route_to_discovery():
+ for connector_type in _MCP_ROUTED:
+ assert (
+ CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS[connector_type] == MCP_DISCOVERY_NAME
+ ), connector_type
+
+
+def test_file_connectors_keep_native_routes():
+ assert (
+ CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["GOOGLE_DRIVE_CONNECTOR"]
+ == "google_drive"
+ )
+ assert CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["DROPBOX_CONNECTOR"] == "dropbox"
+ assert CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["ONEDRIVE_CONNECTOR"] == "onedrive"
+
+
+def test_deprecated_connectors_have_no_route():
+ for connector_type in ("DISCORD_CONNECTOR", "TEAMS_CONNECTOR", "LUMA_CONNECTOR"):
+ assert connector_type not in CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS
+
+
+def test_discovery_gating_is_any_of():
+ """One connected app is enough to keep ``mcp_discovery``; none excludes it."""
+ assert MCP_DISCOVERY_NAME not in get_subagents_to_exclude(["SLACK_CONNECTOR"])
+ assert MCP_DISCOVERY_NAME in get_subagents_to_exclude([])
+ # A generic user MCP server alone still unlocks it.
+ assert MCP_DISCOVERY_NAME not in get_subagents_to_exclude(["MCP_CONNECTOR"])
+
+
+def test_discovery_gating_tokens_match_routed_connectors():
+ assert SUBAGENT_TO_REQUIRED_CONNECTOR_MAP[MCP_DISCOVERY_NAME] == frozenset(
+ _MCP_ROUTED
+ )
+
+
+def test_legacy_aliases_resolve_to_a_live_subagent():
+ """Old per-connector task names must alias onto a subagent that still exists."""
+ for legacy, target in LEGACY_SUBAGENT_ALIASES.items():
+ assert legacy not in SUBAGENT_BUILDERS_BY_NAME, legacy
+ assert target in SUBAGENT_BUILDERS_BY_NAME, target
+
+
+def test_collision_only_prefixes_shared_names():
+ """A name on two connectors is prefixed; a unique name is left untouched."""
+ tools = [
+ _tool("search", {"mcp_connector_id": 1, "mcp_transport": "http"}),
+ _tool("search", {"mcp_connector_id": 2, "mcp_transport": "http"}),
+ _tool("list_bases", {"mcp_connector_id": 2, "mcp_transport": "http"}),
+ ]
+ resolved = {
+ t.name: t
+ for t in resolve_tool_name_collisions(
+ tools, {1: "NOTION_CONNECTOR", 2: "AIRTABLE_CONNECTOR"}
+ )
+ }
+
+ # The unique tool keeps its bare name (trusted_tools / history stay valid).
+ assert "list_bases" in resolved
+ # The colliding name is gone; both are prefixed with service + connector id.
+ assert "search" not in resolved
+ assert "notion_1_search" in resolved
+ assert "airtable_2_search" in resolved
+ # Original name preserved for the "Always Allow" fallback key.
+ for name in ("notion_1_search", "airtable_2_search"):
+ meta = resolved[name].metadata or {}
+ assert meta["mcp_original_tool_name"] == "search"
+ assert meta["mcp_collision_prefixed"] is True
+
+
+def test_collision_noop_without_collisions():
+ tools = [
+ _tool("a", {"mcp_connector_id": 1}),
+ _tool("b", {"mcp_connector_id": 2}),
+ ]
+ assert [t.name for t in resolve_tool_name_collisions(tools, {})] == ["a", "b"]
+
+
+def test_ruleset_reads_hitl_from_metadata():
+ """Read-only MCP tools ``allow``; every other MCP tool ``ask``; natives skip."""
+ tools = [
+ _tool("readonly_search", {"mcp_transport": "http", "hitl": False}),
+ _tool("mutating_create", {"mcp_transport": "http", "hitl": True}),
+ _tool("native_helper", {}), # no mcp_transport => no rule
+ ]
+ rules = {r.permission: r.action for r in build_ruleset(tools).rules}
+ assert rules == {"readonly_search": "allow", "mutating_create": "ask"}
+
+
+def test_indexing_deprecation_sets():
+ """Indexing-only connectors are deprecated; migrated ones are LIVE; Obsidian stays."""
+ deprecated = {t.value for t in DEPRECATED_INDEXING_CONNECTOR_TYPES}
+ assert deprecated == {
+ "GITHUB_CONNECTOR",
+ "BOOKSTACK_CONNECTOR",
+ "ELASTICSEARCH_CONNECTOR",
+ "CIRCLEBACK_CONNECTOR",
+ }
+ assert "OBSIDIAN_CONNECTOR" not in deprecated
+
+ live = {t.value for t in LIVE_CONNECTOR_TYPES}
+ assert {"NOTION_CONNECTOR", "CONFLUENCE_CONNECTOR"} <= live
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_prompt_resources.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_prompt_resources.py
index ccdfc0b98..bbe64fad3 100644
--- a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_prompt_resources.py
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_prompt_resources.py
@@ -45,7 +45,7 @@ def test_every_subagent_has_description_md(name: str):
[
"core_behavior.md",
"routing.md",
- "tools/web_search/description.md",
+ "tools/task/description.md",
],
)
def test_main_agent_prompt_fragments_resolve(filename: str):
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
index 157f1703b..c3ad04250 100644
--- a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
@@ -19,36 +19,32 @@ from app.agents.chat.multi_agent_chat.subagents.registry import (
pytestmark = pytest.mark.unit
-# The full specialist roster the main agent composes from: 4 builtins + 15
-# connector routes. Adding/removing a specialist is a deliberate product change
-# and must be reflected here.
+# The full specialist roster the main agent composes from after the MCP
+# migration: builtins + the three file-connector routes (Drive/Dropbox/
+# OneDrive). Every MCP-backed connector (Slack/Jira/Linear/ClickUp/Airtable/
+# Notion/Confluence/Gmail/Calendar) now lives behind the single
+# ``mcp_discovery`` route; Discord/Teams/Luma were deprecated. Adding/removing a
+# specialist is a deliberate product change and must be reflected here.
_EXPECTED_SUBAGENTS = frozenset(
{
- "airtable",
- "calendar",
- "clickup",
- "confluence",
"deliverables",
- "discord",
"dropbox",
- "gmail",
"google_drive",
- "jira",
+ "google_maps",
+ "google_search",
"knowledge_base",
- "linear",
- "luma",
+ "mcp_discovery",
"memory",
- "notion",
"onedrive",
- "research",
- "slack",
- "teams",
+ "reddit",
+ "web_crawler",
+ "youtube",
}
)
# Specialists that are always available regardless of connected sources, so they
# carry no required-connector entry.
-_CONNECTORLESS = frozenset({"memory", "research"})
+_CONNECTORLESS = frozenset({"memory"})
def test_registry_contains_exactly_expected_subagents():
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_web_search_removed.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_web_search_removed.py
new file mode 100644
index 000000000..48c47845d
--- /dev/null
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_web_search_removed.py
@@ -0,0 +1,48 @@
+"""Guardrail: the multi-engine ``web_search`` tool is fully retired.
+
+Public web search now runs exclusively through the ``google_search`` subagent,
+and the four search connector types (Tavily/SearXNG/Linkup/Baidu) are soft-
+deprecated. This pins those invariants so a future change can't quietly bring
+the ``web_search`` tool back or un-deprecate a search connector.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+
+from app.agents.chat.multi_agent_chat.main_agent.tools.index import (
+ MAIN_AGENT_SURFSENSE_TOOL_NAMES,
+)
+from app.agents.chat.multi_agent_chat.subagents.registry import (
+ SUBAGENT_BUILDERS_BY_NAME,
+)
+from app.utils.validators import (
+ DEPRECATED_CONNECTOR_TYPES,
+ raise_if_connector_deprecated,
+)
+
+pytestmark = pytest.mark.unit
+
+_DEPRECATED_SEARCH_TYPES = (
+ "TAVILY_API",
+ "SEARXNG_API",
+ "LINKUP_API",
+ "BAIDU_SEARCH_API",
+)
+
+
+def test_web_search_tool_removed_from_main_agent():
+ assert "web_search" not in MAIN_AGENT_SURFSENSE_TOOL_NAMES
+
+
+def test_google_search_specialist_present():
+ assert "google_search" in SUBAGENT_BUILDERS_BY_NAME
+
+
+@pytest.mark.parametrize("connector_type", _DEPRECATED_SEARCH_TYPES)
+def test_search_connectors_deprecated(connector_type):
+ assert connector_type in DEPRECATED_CONNECTOR_TYPES
+ with pytest.raises(HTTPException) as excinfo:
+ raise_if_connector_deprecated(connector_type)
+ assert excinfo.value.status_code == 410
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_action_log.py b/surfsense_backend/tests/unit/agents/new_chat/test_action_log.py
index e476538bd..30f45f2e4 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_action_log.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_action_log.py
@@ -94,7 +94,7 @@ def fake_session_factory():
class TestActionLogMiddlewareDisabled:
@pytest.mark.asyncio
async def test_no_op_when_flag_off(self, patch_get_flags) -> None:
- mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
+ mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
request = _FakeRequest(
tool_call={
"name": "make_widget",
@@ -110,7 +110,7 @@ class TestActionLogMiddlewareDisabled:
@pytest.mark.asyncio
async def test_no_op_when_thread_id_none(self, patch_get_flags) -> None:
- mw = ActionLogMiddleware(thread_id=None, search_space_id=1, user_id=None)
+ mw = ActionLogMiddleware(thread_id=None, workspace_id=1, user_id=None)
request = _FakeRequest(
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
)
@@ -126,7 +126,7 @@ class TestActionLogMiddlewarePersistence:
self, patch_get_flags, fake_session_factory
) -> None:
captured, factory = fake_session_factory
- mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
+ mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
request = _FakeRequest(
tool_call={
"name": "make_widget",
@@ -150,7 +150,7 @@ class TestActionLogMiddlewarePersistence:
assert len(captured["rows"]) == 1
row = captured["rows"][0]
assert row.thread_id == 42
- assert row.search_space_id == 7
+ assert row.workspace_id == 7
assert row.user_id == "u1"
assert row.tool_name == "make_widget"
assert row.args == {"color": "red", "size": 3}
@@ -170,7 +170,7 @@ class TestActionLogMiddlewarePersistence:
) -> None:
"""``chat_turn_id`` falls back to NULL when ``runtime.config`` is absent."""
captured, factory = fake_session_factory
- mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
+ mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
request = _FakeRequest(
tool_call={"name": "make_widget", "args": {}, "id": "tc-1"},
runtime=None,
@@ -190,7 +190,7 @@ class TestActionLogMiddlewarePersistence:
self, patch_get_flags, fake_session_factory
) -> None:
captured, factory = fake_session_factory
- mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
+ mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
request = _FakeRequest(
tool_call={"name": "make_widget", "args": {"color": "red"}, "id": "tc1"}
)
@@ -214,7 +214,7 @@ class TestActionLogMiddlewarePersistence:
self, patch_get_flags
) -> None:
"""Even if the DB write blows up, the tool's result must reach the model."""
- mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
+ mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
request = _FakeRequest(
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
)
@@ -250,7 +250,7 @@ class TestReverseDescriptor:
)
mw = ActionLogMiddleware(
thread_id=1,
- search_space_id=1,
+ workspace_id=1,
user_id="u",
tool_definitions={"make_widget": tool_def},
)
@@ -296,7 +296,7 @@ class TestReverseDescriptor:
)
mw = ActionLogMiddleware(
thread_id=1,
- search_space_id=1,
+ workspace_id=1,
user_id=None,
tool_definitions={"make_widget": tool_def},
)
@@ -321,7 +321,7 @@ class TestReverseDescriptor:
self, patch_get_flags, fake_session_factory
) -> None:
captured, factory = fake_session_factory
- mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
+ mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
request = _FakeRequest(
tool_call={"name": "unknown_tool", "args": {}, "id": "tc1"}
)
@@ -343,7 +343,7 @@ class TestActionLogDispatch:
self, patch_get_flags, fake_session_factory
) -> None:
_captured, factory = fake_session_factory
- mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
+ mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
request = _FakeRequest(
tool_call={
"name": "make_widget",
@@ -383,7 +383,7 @@ class TestActionLogDispatch:
@pytest.mark.asyncio
async def test_no_dispatch_when_persistence_fails(self, patch_get_flags) -> None:
"""If commit fails the dispatch is suppressed (no row to surface)."""
- mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
+ mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
request = _FakeRequest(
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
)
@@ -411,7 +411,7 @@ class TestArgsTruncation:
self, patch_get_flags, fake_session_factory
) -> None:
captured, factory = fake_session_factory
- mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
+ mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
# Build a > 32KB string so the persisted payload triggers the truncation path.
huge = "x" * (40 * 1024)
request = _FakeRequest(
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_context_editing.py b/surfsense_backend/tests/unit/agents/new_chat/test_context_editing.py
index 9632fd14d..f80e38154 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_context_editing.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_context_editing.py
@@ -59,7 +59,7 @@ class TestSpillEdit:
assert edit.pending_spills == []
def test_above_trigger_clears_and_records(self) -> None:
- edit = SpillToBackendEdit(trigger=100, keep=1, path_prefix="/tool_outputs")
+ edit = SpillToBackendEdit(trigger=100, keep=1)
msgs = _build_history(4)
edit.apply(msgs, count_tokens=_approx_count)
@@ -102,7 +102,9 @@ class TestSpillEdit:
assert edit.drain_pending() == []
def test_placeholder_format(self) -> None:
- path = "/tool_outputs/thread-1/tool-msg-0.txt"
- text = _build_spill_placeholder(path)
- assert path in text
- assert "explore" in text # mentions the recovery agent
+ import uuid
+
+ spill_id = uuid.uuid4()
+ text = _build_spill_placeholder(spill_id)
+ assert f"spill_{spill_id}" in text
+ assert "read_run" in text # points at the recovery tools
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py b/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py
index b6341bfec..8c559af4d 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py
@@ -9,7 +9,7 @@ earliest -> latest:
3. (future) user-defined rules from the Agent Permissions UI
Without #1 every read-only built-in (``ls``, ``read_file``, ``grep``,
-``glob``, ``web_search`` …) defaulted to ``ask`` because
+``glob`` …) defaulted to ``ask`` because
``permissions.evaluate`` returns ``ask`` when no rule matches. That
caused two production-painful behaviors:
@@ -58,8 +58,6 @@ class TestReadOnlyToolsAllowed:
"read_file",
"grep",
"glob",
- "web_search",
- "scrape_webpage",
"get_connected_accounts",
"write_todos",
"task",
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_mention_resolver.py b/surfsense_backend/tests/unit/agents/new_chat/test_mention_resolver.py
index 6aebee093..b568f5a99 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_mention_resolver.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_mention_resolver.py
@@ -99,7 +99,7 @@ class TestResolveMentions:
session.execute = AsyncMock()
result = await resolve_mentions(
session,
- search_space_id=1,
+ workspace_id=1,
mentioned_documents=None,
)
assert isinstance(result, ResolvedMentionSet)
@@ -134,7 +134,7 @@ class TestResolveMentions:
out = await resolve_mentions(
session,
- search_space_id=5,
+ workspace_id=5,
mentioned_documents=[chip],
)
assert len(out.mentions) == 1
@@ -170,7 +170,7 @@ class TestResolveMentions:
out = await resolve_mentions(
session,
- search_space_id=3,
+ workspace_id=3,
mentioned_documents=[chip],
)
assert len(out.mentions) == 1
@@ -201,7 +201,7 @@ class TestResolveMentions:
out = await resolve_mentions(
session,
- search_space_id=1,
+ workspace_id=1,
mentioned_documents=[chip],
)
assert out.mentions == []
@@ -238,7 +238,7 @@ class TestResolveMentions:
out = await resolve_mentions(
session,
- search_space_id=1,
+ workspace_id=1,
mentioned_documents=[chip_short, chip_long],
)
tokens = [tok for tok, _ in out.token_to_path]
@@ -265,7 +265,7 @@ class TestResolveMentions:
out = await resolve_mentions(
session,
- search_space_id=2,
+ workspace_id=2,
mentioned_documents=None,
mentioned_document_ids=[7],
)
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_otel_span.py b/surfsense_backend/tests/unit/agents/new_chat/test_otel_span.py
index e2978d277..07d05d8a4 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_otel_span.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_otel_span.py
@@ -65,8 +65,8 @@ class TestResolveToolName:
def test_falls_back_to_tool_call_name(self) -> None:
request = MagicMock()
request.tool = None
- request.tool_call = {"name": "web_search", "args": {}}
- assert _resolve_tool_name(request) == "web_search"
+ request.tool_call = {"name": "create_automation", "args": {}}
+ assert _resolve_tool_name(request) == "create_automation"
def test_unknown_when_nothing_resolves(self) -> None:
request = MagicMock()
@@ -278,8 +278,8 @@ class TestMiddlewareIntegration:
request = MagicMock()
request.tool = MagicMock()
- request.tool.name = "web_search"
+ request.tool.name = "scrape_webpage"
await mw.awrap_tool_call(request, handler)
- assert errors == ["web_search"]
+ assert errors == ["scrape_webpage"]
finally:
ot.reload_for_tests()
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_path_resolver.py b/surfsense_backend/tests/unit/agents/new_chat/test_path_resolver.py
index 2617bff8e..ba9dc242b 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_path_resolver.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_path_resolver.py
@@ -149,7 +149,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc(
session,
- search_space_id=5,
+ workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/{encoded_basename}",
)
assert document is target_doc
@@ -169,7 +169,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc(
session,
- search_space_id=5,
+ workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/Calendar_ Happy birthday!.xml",
)
assert document is None
@@ -191,7 +191,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc(
session,
- search_space_id=5,
+ workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/Plain Note.xml",
)
assert document is target_doc
@@ -217,7 +217,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc(
session,
- search_space_id=5,
+ workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf.xml",
)
assert document is target_doc
@@ -239,7 +239,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc(
session,
- search_space_id=5,
+ workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf",
)
assert document is target_doc
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_plugin_loader.py b/surfsense_backend/tests/unit/agents/new_chat/test_plugin_loader.py
index 9b3931549..6477bc672 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_plugin_loader.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_plugin_loader.py
@@ -30,7 +30,7 @@ class _DummyMiddleware(AgentMiddleware):
def _ctx() -> PluginContext:
return PluginContext.build(
- search_space_id=1,
+ workspace_id=1,
user_id="u",
thread_visibility="PRIVATE", # type: ignore[arg-type]
llm=MagicMock(),
@@ -165,12 +165,12 @@ class TestPluginContext:
def test_build_includes_required_fields(self) -> None:
llm = MagicMock()
ctx = PluginContext.build(
- search_space_id=42,
+ workspace_id=42,
user_id="user-1",
thread_visibility="PRIVATE", # type: ignore[arg-type]
llm=llm,
)
- assert ctx["search_space_id"] == 42
+ assert ctx["workspace_id"] == 42
assert ctx["user_id"] == "user-1"
assert ctx["llm"] is llm
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_skills_backends.py b/surfsense_backend/tests/unit/agents/new_chat/test_skills_backends.py
index 1c497d99b..f068a8ca4 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_skills_backends.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_skills_backends.py
@@ -11,7 +11,7 @@ from app.agents.chat.multi_agent_chat.main_agent.skills.backends import (
SKILLS_BUILTIN_PREFIX,
SKILLS_SPACE_PREFIX,
BuiltinSkillsBackend,
- SearchSpaceSkillsBackend,
+ WorkspaceSkillsBackend,
build_skills_backend_factory,
default_skills_sources,
)
@@ -176,7 +176,7 @@ class _FakeKBBackend:
return out
-class TestSearchSpaceSkillsBackend:
+class TestWorkspaceSkillsBackend:
def test_remaps_paths_when_listing(self) -> None:
listing = [
{"path": "/documents/_skills/policy", "is_dir": True},
@@ -184,7 +184,7 @@ class TestSearchSpaceSkillsBackend:
{"path": "/documents/other-folder/x.md", "is_dir": False},
]
kb = _FakeKBBackend(listing=listing, file_contents={})
- backend = SearchSpaceSkillsBackend(kb)
+ backend = WorkspaceSkillsBackend(kb)
infos = asyncio.run(backend.als_info("/"))
assert kb.last_ls_path == "/documents/_skills"
paths = [info["path"] for info in infos]
@@ -200,7 +200,7 @@ class TestSearchSpaceSkillsBackend:
"/documents/_skills/policy/SKILL.md": b"---\nname: policy\n---\n",
},
)
- backend = SearchSpaceSkillsBackend(kb)
+ backend = WorkspaceSkillsBackend(kb)
responses = asyncio.run(backend.adownload_files(["/policy/SKILL.md"]))
assert kb.last_download_paths == ["/documents/_skills/policy/SKILL.md"]
assert responses[0].path == "/policy/SKILL.md"
@@ -208,7 +208,7 @@ class TestSearchSpaceSkillsBackend:
assert responses[0].content is not None
def test_sync_methods_raise_not_implemented(self) -> None:
- backend = SearchSpaceSkillsBackend(_FakeKBBackend([], {}))
+ backend = WorkspaceSkillsBackend(_FakeKBBackend([], {}))
with pytest.raises(NotImplementedError):
backend.ls_info("/")
with pytest.raises(NotImplementedError):
@@ -221,7 +221,7 @@ class TestSearchSpaceSkillsBackend:
],
file_contents={},
)
- backend = SearchSpaceSkillsBackend(kb, kb_root="/skills_admin")
+ backend = WorkspaceSkillsBackend(kb, kb_root="/skills_admin")
infos = asyncio.run(backend.als_info("/"))
assert kb.last_ls_path == "/skills_admin"
assert infos[0]["path"] == "/x"
diff --git a/surfsense_backend/tests/unit/agents/new_chat/tools/test_resume_page_limits.py b/surfsense_backend/tests/unit/agents/new_chat/tools/test_resume_page_limits.py
index 61fa87b76..5dbdfd7ae 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/tools/test_resume_page_limits.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/tools/test_resume_page_limits.py
@@ -148,7 +148,7 @@ async def test_generate_resume_defaults_to_one_page_target(monkeypatch) -> None:
monkeypatch.setattr(resume_tool, "_compile_typst", lambda _source: b"pdf")
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: 1)
- tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
+ tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
result = await _invoke(tool, {"user_info": "Jane Doe experience"})
assert result["status"] == "ready"
@@ -178,7 +178,7 @@ async def test_generate_resume_compresses_when_over_limit(monkeypatch) -> None:
page_counts = iter([2, 1])
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
- tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
+ tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
assert result["status"] == "ready"
@@ -213,7 +213,7 @@ async def test_generate_resume_returns_ready_when_target_not_met(monkeypatch) ->
page_counts = iter([3, 3, 2])
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
- tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
+ tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
assert result["status"] == "ready"
@@ -246,7 +246,7 @@ async def test_generate_resume_fails_when_hard_limit_exceeded(monkeypatch) -> No
page_counts = iter([7, 6, 6])
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
- tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
+ tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
assert result["status"] == "failed"
diff --git a/surfsense_backend/tests/unit/automations/actions/builtin/agent_task/test_dependencies.py b/surfsense_backend/tests/unit/automations/actions/builtin/agent_task/test_dependencies.py
index f5709e517..895eae571 100644
--- a/surfsense_backend/tests/unit/automations/actions/builtin/agent_task/test_dependencies.py
+++ b/surfsense_backend/tests/unit/automations/actions/builtin/agent_task/test_dependencies.py
@@ -1,10 +1,10 @@
"""Lock the runtime model-policy backstop in ``build_dependencies``.
Automations resolve their LLM from the *captured* ``chat_model_id`` snapshot (so
-runs are insulated from later chat/search-space model changes), and the model
+runs are insulated from later chat/workspace model changes), and the model
policy is re-checked at run time so a captured model that is no longer billable
fails the run clearly. When no snapshot is present, resolution falls back to the
-live search space.
+live workspace.
"""
from __future__ import annotations
@@ -25,66 +25,66 @@ pytestmark = pytest.mark.unit
class _FakeSession:
- """Minimal async session whose ``get`` returns a preset search space."""
+ """Minimal async session whose ``get`` returns a preset workspace."""
- def __init__(self, search_space: Any) -> None:
- self._search_space = search_space
+ def __init__(self, workspace: Any) -> None:
+ self._workspace = workspace
async def get(self, _model: Any, _pk: int) -> Any:
- return self._search_space
+ return self._workspace
@pytest.fixture
def patched_side_effects(monkeypatch: pytest.MonkeyPatch):
"""Stub the connector setup + checkpointer so only policy/LLM logic runs."""
- async def _fake_setup(_session, *, search_space_id):
- return (SimpleNamespace(name="connector"), "fc-key")
+ async def _fake_setup(_session, *, workspace_id):
+ return SimpleNamespace(name="connector")
- monkeypatch.setattr(deps_mod, "setup_connector_and_firecrawl", _fake_setup)
+ monkeypatch.setattr(deps_mod, "setup_connector_service", _fake_setup)
return None
async def test_build_dependencies_resolves_captured_chat_model_id(
monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None:
- """The bundle loads with the *captured* ``chat_model_id``, not the live search space."""
+ """The bundle loads with the *captured* ``chat_model_id``, not the live workspace."""
captured: dict[str, Any] = {}
- async def _fake_load(_session, *, config_id, search_space_id):
+ async def _fake_load(_session, *, config_id, workspace_id):
captured["config_id"] = config_id
- captured["search_space_id"] = search_space_id
+ captured["workspace_id"] = workspace_id
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
# Captured path validates the explicit ids; passes for this test.
monkeypatch.setattr(deps_mod, "assert_models_billable", lambda **_kw: None)
- # A different value on the live search space proves we ignore it when a
+ # A different value on the live workspace proves we ignore it when a
# snapshot is supplied.
monkeypatch.setattr(
deps_mod,
"assert_automation_models_billable",
- lambda _ss: pytest.fail("search-space policy should not run on captured path"),
+ lambda _ss: pytest.fail("workspace policy should not run on captured path"),
)
- search_space = SimpleNamespace(chat_model_id=-99)
+ workspace = SimpleNamespace(chat_model_id=-99)
result = await build_dependencies(
- session=_FakeSession(search_space),
- search_space_id=42,
+ session=_FakeSession(workspace),
+ workspace_id=42,
chat_model_id=-7,
image_gen_model_id=5,
vision_model_id=-1,
)
- assert captured == {"config_id": -7, "search_space_id": 42}
+ assert captured == {"config_id": -7, "workspace_id": 42}
assert result.llm.name == "llm"
- assert result.firecrawl_api_key == "fc-key"
+ assert result.connector_service.name == "connector"
async def test_build_dependencies_validates_captured_ids(
monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None:
- """The captured ids (not the search space) are what gets policy-checked."""
+ """The captured ids (not the workspace) are what gets policy-checked."""
seen: dict[str, Any] = {}
def _capture(**kwargs):
@@ -92,14 +92,14 @@ async def test_build_dependencies_validates_captured_ids(
monkeypatch.setattr(deps_mod, "assert_models_billable", _capture)
- async def _fake_load(_session, *, config_id, search_space_id):
+ async def _fake_load(_session, *, config_id, workspace_id):
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
await build_dependencies(
session=_FakeSession(SimpleNamespace(chat_model_id=0)),
- search_space_id=42,
+ workspace_id=42,
chat_model_id=-7,
image_gen_model_id=5,
vision_model_id=-1,
@@ -132,20 +132,20 @@ async def test_build_dependencies_raises_on_captured_policy_violation(
with pytest.raises(DependencyError):
await build_dependencies(
session=_FakeSession(SimpleNamespace(chat_model_id=-7)),
- search_space_id=42,
+ workspace_id=42,
chat_model_id=-7,
image_gen_model_id=-2,
vision_model_id=-1,
)
-async def test_build_dependencies_falls_back_to_search_space(
+async def test_build_dependencies_falls_back_to_workspace(
monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None:
- """With no captured snapshot, resolve + validate the live search space."""
+ """With no captured snapshot, resolve + validate the live workspace."""
captured: dict[str, Any] = {}
- async def _fake_load(_session, *, config_id, search_space_id):
+ async def _fake_load(_session, *, config_id, workspace_id):
captured["config_id"] = config_id
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
@@ -157,18 +157,16 @@ async def test_build_dependencies_falls_back_to_search_space(
lambda **_kw: pytest.fail("captured policy should not run on fallback path"),
)
- search_space = SimpleNamespace(chat_model_id=-7)
- result = await build_dependencies(
- session=_FakeSession(search_space), search_space_id=42
- )
+ workspace = SimpleNamespace(chat_model_id=-7)
+ result = await build_dependencies(session=_FakeSession(workspace), workspace_id=42)
assert captured == {"config_id": -7}
assert result.llm.name == "llm"
-async def test_build_dependencies_raises_when_search_space_missing(
+async def test_build_dependencies_raises_when_workspace_missing(
patched_side_effects,
) -> None:
- """A missing search space (fallback path) surfaces as a ``DependencyError``."""
+ """A missing workspace (fallback path) surfaces as a ``DependencyError``."""
with pytest.raises(DependencyError):
- await build_dependencies(session=_FakeSession(None), search_space_id=999)
+ await build_dependencies(session=_FakeSession(None), workspace_id=999)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/__init__.py b/surfsense_backend/tests/unit/automations/api/__init__.py
similarity index 100%
rename from surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/__init__.py
rename to surfsense_backend/tests/unit/automations/api/__init__.py
diff --git a/surfsense_backend/tests/unit/automations/runtime/test_execute_step.py b/surfsense_backend/tests/unit/automations/runtime/test_execute_step.py
index 9b203fdba..c2fedbcaf 100644
--- a/surfsense_backend/tests/unit/automations/runtime/test_execute_step.py
+++ b/surfsense_backend/tests/unit/automations/runtime/test_execute_step.py
@@ -34,7 +34,7 @@ def _action_context() -> ActionContext:
session=cast(AsyncSession, None),
run_id=1,
step_id="s1",
- search_space_id=1,
+ workspace_id=1,
creator_user_id=None,
)
diff --git a/surfsense_backend/tests/unit/automations/runtime/test_executor_action_ctx.py b/surfsense_backend/tests/unit/automations/runtime/test_executor_action_ctx.py
index c89624fbf..646c0fcc0 100644
--- a/surfsense_backend/tests/unit/automations/runtime/test_executor_action_ctx.py
+++ b/surfsense_backend/tests/unit/automations/runtime/test_executor_action_ctx.py
@@ -1,6 +1,6 @@
"""Lock that the executor propagates the captured model snapshot into the
``ActionContext``, so runs resolve their own model (insulated from chat /
-search-space changes) and not the live search space.
+workspace changes) and not the live workspace.
"""
from __future__ import annotations
@@ -21,7 +21,7 @@ pytestmark = pytest.mark.unit
def _run() -> SimpleNamespace:
return SimpleNamespace(
id=1,
- automation=SimpleNamespace(search_space_id=42, created_by_user_id="u-1"),
+ automation=SimpleNamespace(workspace_id=42, created_by_user_id="u-1"),
)
@@ -39,7 +39,7 @@ def test_build_action_ctx_propagates_captured_models() -> None:
models,
)
- assert ctx.search_space_id == 42
+ assert ctx.workspace_id == 42
assert ctx.chat_model_id == -1
assert ctx.image_gen_model_id == 5
assert ctx.vision_model_id == -1
diff --git a/surfsense_backend/tests/unit/automations/schemas/api/test_api_automation.py b/surfsense_backend/tests/unit/automations/schemas/api/test_api_automation.py
index 6ae3ce794..1471778e1 100644
--- a/surfsense_backend/tests/unit/automations/schemas/api/test_api_automation.py
+++ b/surfsense_backend/tests/unit/automations/schemas/api/test_api_automation.py
@@ -17,11 +17,11 @@ _VALID_DEFINITION = {
def test_automation_create_accepts_valid_minimal_payload() -> None:
- """Happy path: just search_space_id, name, and a valid definition.
+ """Happy path: just workspace_id, name, and a valid definition.
Triggers default to ``[]`` so users can attach them later."""
payload = AutomationCreate.model_validate(
{
- "search_space_id": 1,
+ "workspace_id": 1,
"name": "Daily digest",
"definition": _VALID_DEFINITION,
}
@@ -39,7 +39,7 @@ def test_automation_create_cascades_validation_into_nested_definition() -> None:
with pytest.raises(ValidationError):
AutomationCreate.model_validate(
{
- "search_space_id": 1,
+ "workspace_id": 1,
"name": "Bad",
"definition": {"name": "X", "plan": []}, # empty plan
}
@@ -51,7 +51,7 @@ def test_automation_create_rejects_unknown_top_level_field() -> None:
with pytest.raises(ValidationError):
AutomationCreate.model_validate(
{
- "search_space_id": 1,
+ "workspace_id": 1,
"name": "X",
"definition": _VALID_DEFINITION,
"owner": "tg", # not allowed
@@ -64,7 +64,7 @@ def test_automation_create_rejects_empty_name() -> None:
with pytest.raises(ValidationError):
AutomationCreate.model_validate(
{
- "search_space_id": 1,
+ "workspace_id": 1,
"name": "",
"definition": _VALID_DEFINITION,
}
diff --git a/surfsense_backend/tests/unit/automations/services/test_automation_service_policy.py b/surfsense_backend/tests/unit/automations/services/test_automation_service_policy.py
index 477d927e2..ce13512a6 100644
--- a/surfsense_backend/tests/unit/automations/services/test_automation_service_policy.py
+++ b/surfsense_backend/tests/unit/automations/services/test_automation_service_policy.py
@@ -1,6 +1,6 @@
"""Lock creation-time model-policy enforcement in ``AutomationService``.
-Creation (REST + manual builder) rejects search spaces whose models aren't
+Creation (REST + manual builder) rejects workspaces whose models aren't
billable for automations with HTTP 422, mirroring the runtime backstop. These
tests isolate the new ``_assert_models_billable`` / ``model_eligibility`` paths
without touching the DB commit.
@@ -29,13 +29,13 @@ pytestmark = pytest.mark.unit
class _FakeSession:
- def __init__(self, search_space: Any) -> None:
- self._search_space = search_space
+ def __init__(self, workspace: Any) -> None:
+ self._workspace = workspace
self.added: list[Any] = []
self.commits = 0
async def get(self, _model: Any, _pk: int) -> Any:
- return self._search_space
+ return self._workspace
def add(self, obj: Any) -> None:
self.added.append(obj)
@@ -44,9 +44,9 @@ class _FakeSession:
self.commits += 1
-def _service(search_space: Any) -> AutomationService:
+def _service(workspace: Any) -> AutomationService:
return AutomationService(
- session=_FakeSession(search_space),
+ session=_FakeSession(workspace),
auth=AuthContext.session(SimpleNamespace(id="u-1")),
)
@@ -81,7 +81,7 @@ async def test_assert_models_billable_raises_422_on_violation(
async def test_assert_models_billable_raises_404_when_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """A missing search space is a 404, not a policy error."""
+ """A missing workspace is a 404, not a policy error."""
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None
)
@@ -93,23 +93,23 @@ async def test_assert_models_billable_raises_404_when_missing(
assert exc_info.value.status_code == 404
-async def test_assert_models_billable_returns_search_space_when_ok(
+async def test_assert_models_billable_returns_workspace_when_ok(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """When the policy accepts, the loaded search space is returned for reuse."""
+ """When the policy accepts, the loaded workspace is returned for reuse."""
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None
)
- search_space = SimpleNamespace(chat_model_id=-1)
- service = _service(search_space)
- assert await service._assert_models_billable(1) is search_space
+ workspace = SimpleNamespace(chat_model_id=-1)
+ service = _service(workspace)
+ assert await service._assert_models_billable(1) is workspace
-async def test_create_injects_captured_models_from_search_space(
+async def test_create_injects_captured_models_from_workspace(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """create() snapshots the search space's model prefs onto the definition."""
+ """create() snapshots the workspace's model prefs onto the definition."""
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None
)
@@ -124,14 +124,14 @@ async def test_create_injects_captured_models_from_search_space(
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
- search_space = SimpleNamespace(
+ workspace = SimpleNamespace(
chat_model_id=-1,
image_gen_model_id=5,
vision_model_id=-1,
)
- service = _service(search_space)
+ service = _service(workspace)
payload = AutomationCreate(
- search_space_id=1,
+ workspace_id=1,
name="A",
definition=_definition(),
)
@@ -148,7 +148,7 @@ async def test_create_injects_captured_models_from_search_space(
async def test_create_treats_unset_prefs_as_auto_zero(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """``None`` search-space prefs are captured as ``0`` (Auto) ids."""
+ """``None`` workspace prefs are captured as ``0`` (Auto) ids."""
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None
)
@@ -163,13 +163,13 @@ async def test_create_treats_unset_prefs_as_auto_zero(
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
- search_space = SimpleNamespace(
+ workspace = SimpleNamespace(
chat_model_id=None,
image_gen_model_id=None,
vision_model_id=None,
)
- service = _service(search_space)
- payload = AutomationCreate(search_space_id=1, name="A", definition=_definition())
+ service = _service(workspace)
+ payload = AutomationCreate(workspace_id=1, name="A", definition=_definition())
automation = await service.create(payload)
@@ -185,7 +185,7 @@ async def test_create_honors_selected_models_when_provided(
) -> None:
"""When the payload carries ``definition.models`` they are validated + kept.
- The search-space snapshot path is bypassed entirely (no
+ The workspace snapshot path is bypassed entirely (no
``assert_automation_models_billable`` call).
"""
@@ -217,7 +217,7 @@ async def test_create_honors_selected_models_when_provided(
service = _service(SimpleNamespace(chat_model_id=-99))
payload = AutomationCreate(
- search_space_id=1,
+ workspace_id=1,
name="A",
definition=_definition(
models=AutomationModels(
@@ -257,7 +257,7 @@ async def test_create_rejects_unbillable_selected_models(
service = _service(SimpleNamespace(chat_model_id=-3))
payload = AutomationCreate(
- search_space_id=1,
+ workspace_id=1,
name="A",
definition=_definition(
models=AutomationModels(
@@ -284,7 +284,7 @@ async def test_update_preserves_captured_models(
"vision_model_id": -1,
}
existing = SimpleNamespace(
- search_space_id=1,
+ workspace_id=1,
definition={"name": "A", "plan": [], "models": captured},
version=3,
)
@@ -315,7 +315,7 @@ async def test_update_honors_changed_models_when_valid(
) -> None:
"""A definition edit with a *changed* models block validates + keeps it."""
existing = SimpleNamespace(
- search_space_id=1,
+ workspace_id=1,
definition={
"name": "A",
"plan": [],
@@ -376,7 +376,7 @@ async def test_update_rejects_changed_unbillable_models(
) -> None:
"""A *changed* non-billable models block is rejected with HTTP 422."""
existing = SimpleNamespace(
- search_space_id=1,
+ workspace_id=1,
definition={
"name": "A",
"plan": [],
@@ -438,7 +438,7 @@ async def test_update_keeps_unchanged_models_without_revalidation(
"vision_model_id": -1,
}
existing = SimpleNamespace(
- search_space_id=1,
+ workspace_id=1,
definition={"name": "A", "plan": [], "models": captured},
version=3,
)
@@ -477,7 +477,7 @@ async def test_model_eligibility_authorizes_and_returns_payload(
authorized: dict[str, Any] = {}
async def _fake_check_permission(_session, _user, ss_id, permission, _msg):
- authorized["search_space_id"] = ss_id
+ authorized["workspace_id"] = ss_id
authorized["permission"] = permission
monkeypatch.setattr(automation_mod, "check_permission", _fake_check_permission)
@@ -488,8 +488,8 @@ async def test_model_eligibility_authorizes_and_returns_payload(
)
service = _service(SimpleNamespace(chat_model_id=-2))
- result = await service.model_eligibility(search_space_id=5)
+ result = await service.model_eligibility(workspace_id=5)
assert result == {"allowed": False, "violations": [{"kind": "image"}]}
- assert authorized["search_space_id"] == 5
+ assert authorized["workspace_id"] == 5
assert authorized["permission"] == "automations:read"
diff --git a/surfsense_backend/tests/unit/automations/services/test_model_policy.py b/surfsense_backend/tests/unit/automations/services/test_model_policy.py
index 574f6d9fd..654786d38 100644
--- a/surfsense_backend/tests/unit/automations/services/test_model_policy.py
+++ b/surfsense_backend/tests/unit/automations/services/test_model_policy.py
@@ -24,8 +24,8 @@ from app.automations.services.model_policy import (
pytestmark = pytest.mark.unit
-def _search_space(*, llm: int | None, image: int | None, vision: int | None):
- """Minimal stand-in for the ``SearchSpace`` ORM row the policy reads."""
+def _workspace(*, llm: int | None, image: int | None, vision: int | None):
+ """Minimal stand-in for the ``Workspace`` ORM row the policy reads."""
return SimpleNamespace(
chat_model_id=llm,
image_gen_model_id=image,
@@ -95,15 +95,15 @@ def test_unknown_global_id_is_blocked(kind: str, patched_globals) -> None:
def test_eligibility_all_billable(patched_globals) -> None:
"""Premium LLM + BYOK image + premium vision → allowed, no violations."""
- search_space = _search_space(llm=-1, image=5, vision=-1)
- result = get_automation_model_eligibility(search_space)
+ workspace = _workspace(llm=-1, image=5, vision=-1)
+ result = get_automation_model_eligibility(workspace)
assert result == {"allowed": True, "violations": []}
def test_eligibility_reports_each_violation(patched_globals) -> None:
"""A free LLM, Auto image, and free vision each produce a violation."""
- search_space = _search_space(llm=-2, image=0, vision=-2)
- result = get_automation_model_eligibility(search_space)
+ workspace = _workspace(llm=-2, image=0, vision=-2)
+ result = get_automation_model_eligibility(workspace)
assert result["allowed"] is False
kinds = {v["kind"] for v in result["violations"]}
@@ -115,9 +115,9 @@ def test_eligibility_reports_each_violation(patched_globals) -> None:
def test_assert_raises_with_violations(patched_globals) -> None:
"""``assert_automation_models_billable`` raises when any slot is blocked."""
- search_space = _search_space(llm=0, image=5, vision=-1)
+ workspace = _workspace(llm=0, image=5, vision=-1)
with pytest.raises(AutomationModelPolicyError) as exc_info:
- assert_automation_models_billable(search_space)
+ assert_automation_models_billable(workspace)
assert len(exc_info.value.violations) == 1
assert exc_info.value.violations[0]["kind"] == "chat"
@@ -125,8 +125,8 @@ def test_assert_raises_with_violations(patched_globals) -> None:
def test_assert_passes_when_all_billable(patched_globals) -> None:
"""No exception when every slot is premium or BYOK."""
- search_space = _search_space(llm=3, image=-1, vision=4)
- assert assert_automation_models_billable(search_space) is None
+ workspace = _workspace(llm=3, image=-1, vision=4)
+ assert assert_automation_models_billable(workspace) is None
# --- ID-based core (used by the runtime backstop against captured snapshots) ---
@@ -170,9 +170,9 @@ def test_assert_models_billable_passes(patched_globals) -> None:
)
-def test_search_space_wrapper_delegates_to_core(patched_globals) -> None:
- """The search-space wrapper produces the same result as the ID core."""
- search_space = _search_space(llm=-2, image=0, vision=-2)
- assert get_automation_model_eligibility(search_space) == get_model_eligibility(
+def test_workspace_wrapper_delegates_to_core(patched_globals) -> None:
+ """The workspace wrapper produces the same result as the ID core."""
+ workspace = _workspace(llm=-2, image=0, vision=-2)
+ assert get_automation_model_eligibility(workspace) == get_model_eligibility(
chat_model_id=-2, image_gen_model_id=0, vision_model_id=-2
)
diff --git a/surfsense_backend/tests/unit/automations/templating/test_context.py b/surfsense_backend/tests/unit/automations/templating/test_context.py
index 54f372e77..b6bb75e10 100644
--- a/surfsense_backend/tests/unit/automations/templating/test_context.py
+++ b/surfsense_backend/tests/unit/automations/templating/test_context.py
@@ -25,7 +25,7 @@ def test_build_run_context_exposes_run_inputs_and_steps_namespaces() -> None:
automation_id=7,
automation_name="Weekly digest",
automation_version=3,
- search_space_id=1,
+ workspace_id=1,
creator_id=creator,
trigger_id=11,
trigger_type="schedule",
@@ -41,7 +41,7 @@ def test_build_run_context_exposes_run_inputs_and_steps_namespaces() -> None:
"automation_id": 7,
"automation_name": "Weekly digest",
"automation_version": 3,
- "search_space_id": 1,
+ "workspace_id": 1,
"creator_id": creator,
"trigger_id": 11,
"trigger_type": "schedule",
diff --git a/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_filter.py b/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_filter.py
index 9ddc3503a..7f6b5a3b4 100644
--- a/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_filter.py
+++ b/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_filter.py
@@ -21,9 +21,9 @@ def test_scalar_value_is_implicit_equality() -> None:
def test_multiple_fields_are_anded() -> None:
- flt = {"document_type": "FILE", "search_space_id": 7}
- assert matches(flt, {"document_type": "FILE", "search_space_id": 7}) is True
- assert matches(flt, {"document_type": "FILE", "search_space_id": 9}) is False
+ flt = {"document_type": "FILE", "workspace_id": 7}
+ assert matches(flt, {"document_type": "FILE", "workspace_id": 7}) is True
+ assert matches(flt, {"document_type": "FILE", "workspace_id": 9}) is False
def test_gt_operator_compares_greater_than() -> None:
@@ -97,12 +97,12 @@ def test_missing_field_never_matches_and_never_raises() -> None:
def test_logical_operators_compose_with_fields() -> None:
flt = {
- "search_space_id": 7,
+ "workspace_id": 7,
"$or": [{"document_type": "FILE"}, {"document_type": "WEBPAGE"}],
}
- assert matches(flt, {"search_space_id": 7, "document_type": "FILE"}) is True
- assert matches(flt, {"search_space_id": 9, "document_type": "FILE"}) is False
- assert matches(flt, {"search_space_id": 7, "document_type": "SLACK"}) is False
+ assert matches(flt, {"workspace_id": 7, "document_type": "FILE"}) is True
+ assert matches(flt, {"workspace_id": 9, "document_type": "FILE"}) is False
+ assert matches(flt, {"workspace_id": 7, "document_type": "SLACK"}) is False
def test_unknown_field_operator_raises_filter_error() -> None:
diff --git a/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_inputs.py b/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_inputs.py
index e6191d7a7..a4302e4f0 100644
--- a/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_inputs.py
+++ b/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_inputs.py
@@ -14,7 +14,7 @@ def test_runtime_inputs_flatten_payload_with_event_metadata() -> None:
event = Event(
event_type="document.indexed",
payload={"document_id": 42, "document_type": "FILE"},
- search_space_id=7,
+ workspace_id=7,
)
inputs = event_runtime_inputs(event)
diff --git a/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_match.py b/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_match.py
index d83db97a4..e43e53e2e 100644
--- a/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_match.py
+++ b/surfsense_backend/tests/unit/automations/triggers/builtin/event/test_match.py
@@ -11,7 +11,7 @@ pytestmark = pytest.mark.unit
def _event(event_type: str = "document.indexed", **payload) -> Event:
- return Event(event_type=event_type, payload=payload, search_space_id=7)
+ return Event(event_type=event_type, payload=payload, workspace_id=7)
def test_matches_when_event_type_equal_and_filter_passes() -> None:
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/slack/__init__.py b/surfsense_backend/tests/unit/capabilities/__init__.py
similarity index 100%
rename from surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/slack/__init__.py
rename to surfsense_backend/tests/unit/capabilities/__init__.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/__init__.py b/surfsense_backend/tests/unit/capabilities/access/__init__.py
similarity index 100%
rename from surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/__init__.py
rename to surfsense_backend/tests/unit/capabilities/access/__init__.py
diff --git a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py
new file mode 100644
index 000000000..b2173292f
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py
@@ -0,0 +1,138 @@
+"""The agent door (05): generate one LangChain tool per registry verb."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from pydantic import BaseModel, Field
+
+from app.capabilities.core.types import BillingUnit, Capability
+from app.services.web_crawl_credit_service import InsufficientCreditsError
+
+pytestmark = pytest.mark.asyncio
+
+
+class _EchoInput(BaseModel):
+ text: str = Field(description="The text to echo back.")
+
+
+class _EchoOutput(BaseModel):
+ echoed: str
+
+ @property
+ def billable_units(self) -> int:
+ return 1
+
+
+def _capability(
+ *, name: str, output: _EchoOutput, unit=BillingUnit.WEB_CRAWL
+) -> Capability:
+ async def _executor(payload: _EchoInput) -> _EchoOutput:
+ _executor.seen = payload
+ return output
+
+ cap = Capability(
+ name=name,
+ description=f"{name} does a thing.",
+ input_schema=_EchoInput,
+ output_schema=_EchoOutput,
+ executor=_executor,
+ billing_unit=unit,
+ )
+ cap.executor.seen = None # type: ignore[attr-defined]
+ return cap
+
+
+class _FakeSessionCtx:
+ async def __aenter__(self):
+ return SimpleNamespace()
+
+ async def __aexit__(self, *exc):
+ return False
+
+
+@pytest.fixture
+def isolate(monkeypatch):
+ """Stub the billing session + charge/gate so tools never hit the DB."""
+ from app.capabilities.core.access import agent as mod
+
+ monkeypatch.setattr(mod, "async_session_maker", lambda: _FakeSessionCtx())
+ charge = AsyncMock()
+ gate = AsyncMock()
+ monkeypatch.setattr(mod, "charge_capability", charge)
+ monkeypatch.setattr(mod, "gate_capability", gate)
+ return SimpleNamespace(module=mod, charge=charge, gate=gate)
+
+
+def _verb_tool(tools, name: str):
+ """Pick one capability tool out of the list (readers are appended after)."""
+ return next(t for t in tools if t.name == name)
+
+
+async def test_registry_becomes_one_tool_per_verb_plus_readers(isolate):
+ caps = [
+ _capability(name="web.scrape", output=_EchoOutput(echoed="a")),
+ _capability(name="web.discover", output=_EchoOutput(echoed="b"), unit=None),
+ ]
+
+ tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=caps)
+
+ by_name = {t.name: t for t in tools}
+ # One tool per verb, plus the two shared run-reader tools.
+ assert set(by_name) == {"web_scrape", "web_discover", "read_run", "search_run"}
+ assert by_name["web_scrape"].description == "web.scrape does a thing."
+ assert by_name["web_scrape"].args_schema is _EchoInput
+
+
+async def test_input_field_docs_reach_the_model(isolate):
+ """Per-field descriptions must surface in the tool's args schema (LLM context)."""
+ cap = _capability(name="web.scrape", output=_EchoOutput(echoed="a"))
+ tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
+ tool = _verb_tool(tools, "web_scrape")
+
+ assert tool.args["text"]["description"] == "The text to echo back."
+
+
+async def test_tool_runs_executor_and_returns_serialized_output(isolate):
+ cap = _capability(name="web.scrape", output=_EchoOutput(echoed="hi there"))
+ tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
+ tool = _verb_tool(tools, "web_scrape")
+
+ result = await tool.ainvoke({"text": "ping"})
+
+ # Fake session makes record_run fail -> no run_id key, plain serialized output.
+ assert result == {"echoed": "hi there"}
+ assert cap.executor.seen.text == "ping"
+
+
+async def test_tool_charges_owner(isolate):
+ output = _EchoOutput(echoed="hi")
+ cap = _capability(name="web.scrape", output=output)
+ tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
+ tool = _verb_tool(tools, "web_scrape")
+
+ await tool.ainvoke({"text": "ping"})
+
+ isolate.charge.assert_awaited_once()
+ (charged_output, unit, ctx), _ = isolate.charge.call_args
+ assert charged_output is output
+ assert unit is BillingUnit.WEB_CRAWL
+ assert ctx.workspace_id == 7
+
+
+async def test_over_budget_returns_friendly_message(isolate):
+ cap = _capability(name="web.scrape", output=_EchoOutput(echoed="hi"))
+ isolate.gate.side_effect = InsufficientCreditsError(
+ message="This run would exceed your available credit.",
+ balance_micros=0,
+ required_micros=1_000_000,
+ )
+ tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
+ tool = _verb_tool(tools, "web_scrape")
+
+ result = await tool.ainvoke({"text": "ping"})
+
+ assert isinstance(result, str)
+ assert "credit" in result.lower()
+ assert cap.executor.seen is None
+ isolate.charge.assert_not_awaited()
diff --git a/surfsense_backend/tests/unit/capabilities/access/test_rate_limit.py b/surfsense_backend/tests/unit/capabilities/access/test_rate_limit.py
new file mode 100644
index 000000000..1762c146e
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/access/test_rate_limit.py
@@ -0,0 +1,37 @@
+"""Per-workspace rate limit: a secondary guard behind the credit meter-gate (05)."""
+
+import pytest
+from fastapi import HTTPException
+from starlette.requests import Request
+
+from app.capabilities.core.access import rate_limit
+
+
+def _request(workspace_id: int) -> Request:
+ return Request({"type": "http", "path_params": {"workspace_id": workspace_id}})
+
+
+@pytest.mark.asyncio
+async def test_passes_at_the_limit(monkeypatch):
+ monkeypatch.setattr(
+ rate_limit, "_incr", lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE
+ )
+ await rate_limit.enforce_capability_rate_limit(_request(1))
+
+
+@pytest.mark.asyncio
+async def test_blocks_over_the_limit(monkeypatch):
+ monkeypatch.setattr(
+ rate_limit,
+ "_incr",
+ lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE + 1,
+ )
+ with pytest.raises(HTTPException) as exc:
+ await rate_limit.enforce_capability_rate_limit(_request(1))
+ assert exc.value.status_code == 429
+
+
+def test_memory_fallback_counts_within_window():
+ rate_limit._memory.clear()
+ assert rate_limit._incr_memory("k", window_seconds=60) == 1
+ assert rate_limit._incr_memory("k", window_seconds=60) == 2
diff --git a/surfsense_backend/tests/unit/capabilities/access/test_rest_router.py b/surfsense_backend/tests/unit/capabilities/access/test_rest_router.py
new file mode 100644
index 000000000..a406f29bd
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/access/test_rest_router.py
@@ -0,0 +1,505 @@
+"""The REST door generator turns registry verbs into typed POST routes (05)."""
+
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from httpx import ASGITransport, AsyncClient
+from pydantic import BaseModel
+
+from app.capabilities.core.types import Capability
+from app.db import get_async_session
+from app.users import get_auth_context
+
+
+class _EchoInput(BaseModel):
+ value: str
+
+
+class _EchoOutput(BaseModel):
+ echo: str
+
+
+async def _echo_executor(payload: _EchoInput) -> _EchoOutput:
+ return _EchoOutput(echo=payload.value)
+
+
+_ECHO = Capability(
+ name="test.echo",
+ description="Echo the input back for tests.",
+ input_schema=_EchoInput,
+ output_schema=_EchoOutput,
+ executor=_echo_executor,
+ billing_unit=None,
+)
+
+
+def _build_app(capabilities, monkeypatch) -> FastAPI:
+ """Mount the generated door with auth/workspace/session/rate-limit stubbed."""
+ from app.capabilities.core.access import rest
+ from app.capabilities.core.access.rate_limit import enforce_capability_rate_limit
+
+ monkeypatch.setattr(rest, "check_workspace_access", _noop_async, raising=True)
+ monkeypatch.setattr(rest, "_record_rest_run", _fake_record, raising=True)
+
+ app = FastAPI()
+ app.include_router(rest.build_capabilities_router(capabilities), prefix="/api/v1")
+ app.dependency_overrides[get_auth_context] = lambda: SimpleNamespace(user=None)
+ app.dependency_overrides[enforce_capability_rate_limit] = _allow
+
+ async def _session():
+ yield SimpleNamespace()
+
+ app.dependency_overrides[get_async_session] = _session
+ return app
+
+
+async def _noop_async(*args, **kwargs) -> None:
+ return None
+
+
+async def _fake_record(**kwargs) -> str:
+ """Stand-in for the DB-backed recorder so unit tests never touch a database."""
+ return "test-run-id"
+
+
+async def _allow() -> None:
+ return None
+
+
+def _client(app: FastAPI) -> AsyncClient:
+ return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
+
+
+@pytest.mark.asyncio
+async def test_verb_is_exposed_as_typed_post_route(monkeypatch):
+ app = _build_app([_ECHO], monkeypatch)
+ async with _client(app) as client:
+ resp = await client.post(
+ "/api/v1/workspaces/7/scrapers/test/echo",
+ json={"value": "hi"},
+ )
+ assert resp.status_code == 200
+ assert resp.json() == {"echo": "hi"}
+ assert resp.headers["X-Run-Id"] == "run_test-run-id"
+
+
+@pytest.mark.asyncio
+async def test_input_is_validated_against_the_verb_schema(monkeypatch):
+ app = _build_app([_ECHO], monkeypatch)
+ async with _client(app) as client:
+ resp = await client.post(
+ "/api/v1/workspaces/7/scrapers/test/echo",
+ json={"wrong": "field"},
+ )
+ assert resp.status_code == 422
+
+
+def test_registered_verbs_appear_on_rest():
+ """A verb in the registry shows up as a route with no per-verb wiring."""
+ import app.capabilities.web # noqa: F401 (registers web.* at import)
+ from app.capabilities.core.access import rest
+
+ router = rest.build_capabilities_router()
+ paths = {route.path for route in router.routes}
+ assert "/workspaces/{workspace_id}/scrapers/web/crawl" in paths
+
+
+@pytest.mark.asyncio
+async def test_capabilities_endpoint_lists_verbs_with_input_schema(monkeypatch):
+ """The playground reads verb identity + input JSON schema from one GET."""
+ app = _build_app([_ECHO], monkeypatch)
+ async with _client(app) as client:
+ resp = await client.get("/api/v1/workspaces/7/scrapers/capabilities")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert len(body) == 1
+ entry = body[0]
+ assert entry["name"] == "test.echo"
+ assert entry["description"] == "Echo the input back for tests."
+ # The schemas are the pydantic models' JSON schemas: the form renders the
+ # input schema, the API reference docs render both.
+ assert "value" in entry["input_schema"]["properties"]
+ assert "properties" in entry["output_schema"]
+
+
+@pytest.mark.asyncio
+async def test_capabilities_endpoint_exposes_live_pricing(monkeypatch):
+ """Billed verbs report their per-item rate; free verbs report an empty list."""
+ from app.capabilities.core.types import BillingUnit
+ from app.config import config
+
+ monkeypatch.setattr(config, "PLATFORM_SCRAPE_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "YOUTUBE_MICROS_PER_VIDEO", 2500)
+
+ billed = Capability(
+ name="test.billed",
+ description="Billed verb for tests.",
+ input_schema=_EchoInput,
+ output_schema=_EchoOutput,
+ executor=_echo_executor,
+ billing_unit=BillingUnit.YOUTUBE_VIDEO,
+ )
+
+ app = _build_app([_ECHO, billed], monkeypatch)
+ async with _client(app) as client:
+ resp = await client.get("/api/v1/workspaces/7/scrapers/capabilities")
+ assert resp.status_code == 200
+ by_name = {entry["name"]: entry for entry in resp.json()}
+ assert by_name["test.echo"]["pricing"] == []
+ assert by_name["test.billed"]["pricing"] == [
+ {"unit": "video", "micros_per_unit": 2500}
+ ]
+
+ # Rates are read live: a config retune shows up without a router rebuild.
+ monkeypatch.setattr(config, "PLATFORM_SCRAPE_BILLING_ENABLED", False)
+ async with _client(app) as client:
+ resp = await client.get("/api/v1/workspaces/7/scrapers/capabilities")
+ assert resp.json()[1]["pricing"] == []
+
+
+@pytest.mark.asyncio
+async def test_over_budget_is_blocked_before_the_executor(monkeypatch):
+ from app.capabilities.core.access import rest
+ from app.services.web_crawl_credit_service import InsufficientCreditsError
+
+ async def _raise(*args, **kwargs):
+ raise InsufficientCreditsError(
+ message="over budget", balance_micros=0, required_micros=1000
+ )
+
+ monkeypatch.setattr(rest, "gate_capability", _raise, raising=True)
+
+ app = _build_app([_ECHO], monkeypatch)
+ async with _client(app) as client:
+ resp = await client.post(
+ "/api/v1/workspaces/7/scrapers/test/echo",
+ json={"value": "hi"},
+ )
+ assert resp.status_code == 402
+
+
+@pytest.mark.asyncio
+async def test_rate_limit_blocks_the_workspace(monkeypatch):
+ """The generated route enforces the per-workspace limit (429)."""
+ from app.capabilities.core.access import rate_limit, rest
+
+ monkeypatch.setattr(rest, "check_workspace_access", _noop_async, raising=True)
+ monkeypatch.setattr(
+ rate_limit,
+ "_incr",
+ lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE + 1,
+ )
+
+ app = FastAPI()
+ app.include_router(rest.build_capabilities_router([_ECHO]), prefix="/api/v1")
+ app.dependency_overrides[get_auth_context] = lambda: SimpleNamespace(user=None)
+
+ async def _session():
+ yield SimpleNamespace()
+
+ app.dependency_overrides[get_async_session] = _session
+
+ async with _client(app) as client:
+ resp = await client.post(
+ "/api/v1/workspaces/7/scrapers/test/echo",
+ json={"value": "hi"},
+ )
+ assert resp.status_code == 429
+
+
+def _register_surfsense_handler(app: FastAPI) -> None:
+ """Minimal stand-in for the app's global SurfSenseError handler."""
+ from starlette.responses import JSONResponse
+
+ from app.exceptions import SurfSenseError
+
+ async def _handler(_request, exc: SurfSenseError):
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"code": exc.code, "message": exc.message},
+ )
+
+ app.add_exception_handler(SurfSenseError, _handler)
+
+
+@pytest.mark.asyncio
+async def test_executor_fault_becomes_502(monkeypatch):
+ """Any non-SurfSense executor error is surfaced as a clean 502, not a 500."""
+
+ async def _boom(_payload: _EchoInput) -> _EchoOutput:
+ raise RuntimeError("upstream provider exploded")
+
+ boom = Capability(
+ name="test.boom",
+ description="Always fails for tests.",
+ input_schema=_EchoInput,
+ output_schema=_EchoOutput,
+ executor=_boom,
+ billing_unit=None,
+ )
+
+ app = _build_app([boom], monkeypatch)
+ _register_surfsense_handler(app)
+ async with _client(app) as client:
+ resp = await client.post(
+ "/api/v1/workspaces/7/scrapers/test/boom",
+ json={"value": "hi"},
+ )
+ assert resp.status_code == 502
+ assert resp.json()["code"] == "CAPABILITY_UPSTREAM_ERROR"
+
+
+@pytest.mark.asyncio
+async def test_surfsense_error_passes_through(monkeypatch):
+ """Intentional, status-carrying errors (e.g. a 403 wall) are not remapped."""
+ from app.exceptions import ForbiddenError
+
+ async def _forbidden(_payload: _EchoInput) -> _EchoOutput:
+ raise ForbiddenError("sign in required", code="GOOGLE_SIGNIN_REQUIRED")
+
+ forbidden = Capability(
+ name="test.forbidden",
+ description="Raises a domain 403 for tests.",
+ input_schema=_EchoInput,
+ output_schema=_EchoOutput,
+ executor=_forbidden,
+ billing_unit=None,
+ )
+
+ app = _build_app([forbidden], monkeypatch)
+ _register_surfsense_handler(app)
+ async with _client(app) as client:
+ resp = await client.post(
+ "/api/v1/workspaces/7/scrapers/test/forbidden",
+ json={"value": "hi"},
+ )
+ assert resp.status_code == 403
+ assert resp.json()["code"] == "GOOGLE_SIGNIN_REQUIRED"
+
+
+def _fake_run_row(**overrides):
+ from datetime import UTC, datetime
+ from uuid import uuid4
+
+ defaults = {
+ "id": uuid4(),
+ "capability": "test.echo",
+ "origin": "api",
+ "status": "success",
+ "item_count": 2,
+ "char_count": 42,
+ "duration_ms": 10,
+ "cost_micros": None,
+ "error": None,
+ "created_at": datetime.now(UTC),
+ "thread_id": None,
+ "input": {"value": "hi"},
+ "output_text": '{"echo": "hi"}',
+ "progress": None,
+ }
+ defaults.update(overrides)
+ return SimpleNamespace(**defaults)
+
+
+def _build_app_with_rows(monkeypatch, rows):
+ """App whose fake session answers select() with the given Run-like rows."""
+ from app.capabilities.core.access import rest
+
+ monkeypatch.setattr(rest, "check_workspace_access", _noop_async, raising=True)
+
+ class _Result:
+ def scalars(self):
+ return self
+
+ def all(self):
+ return rows
+
+ def scalar_one_or_none(self):
+ return rows[0] if rows else None
+
+ class _Session:
+ async def execute(self, stmt):
+ return _Result()
+
+ app = FastAPI()
+ app.include_router(rest.build_capabilities_router([]), prefix="/api/v1")
+ app.dependency_overrides[get_auth_context] = lambda: SimpleNamespace(user=None)
+
+ async def _session():
+ yield _Session()
+
+ app.dependency_overrides[get_async_session] = _session
+ return app
+
+
+@pytest.mark.asyncio
+async def test_runs_list_returns_metadata_without_output(monkeypatch):
+ row = _fake_run_row()
+ app = _build_app_with_rows(monkeypatch, [row])
+ async with _client(app) as client:
+ resp = await client.get("/api/v1/workspaces/7/scrapers/runs")
+ assert resp.status_code == 200
+ [item] = resp.json()
+ assert item["id"] == f"run_{row.id}"
+ assert item["capability"] == "test.echo"
+ assert "output_text" not in item # list is metadata-only
+
+
+@pytest.mark.asyncio
+async def test_run_detail_includes_output(monkeypatch):
+ row = _fake_run_row()
+ app = _build_app_with_rows(monkeypatch, [row])
+ async with _client(app) as client:
+ resp = await client.get(f"/api/v1/workspaces/7/scrapers/runs/run_{row.id}")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["output_text"] == '{"echo": "hi"}'
+ assert body["input"] == {"value": "hi"}
+
+
+@pytest.mark.asyncio
+async def test_run_detail_404s(monkeypatch):
+ app = _build_app_with_rows(monkeypatch, [])
+ async with _client(app) as client:
+ missing = await client.get(
+ "/api/v1/workspaces/7/scrapers/runs/run_00000000-0000-0000-0000-000000000000"
+ )
+ malformed = await client.get("/api/v1/workspaces/7/scrapers/runs/garbage")
+ assert missing.status_code == 404
+ assert malformed.status_code == 404 # bad UUID must not become a 500
+
+
+@pytest.mark.asyncio
+async def test_success_charges_once(monkeypatch):
+ from unittest.mock import AsyncMock
+
+ from app.capabilities.core.access import rest
+
+ charge = AsyncMock()
+ monkeypatch.setattr(rest, "charge_capability", charge, raising=True)
+
+ app = _build_app([_ECHO], monkeypatch)
+ async with _client(app) as client:
+ resp = await client.post(
+ "/api/v1/workspaces/7/scrapers/test/echo",
+ json={"value": "hi"},
+ )
+ assert resp.status_code == 200
+ charge.assert_awaited_once()
+ (output, unit, ctx), _ = charge.call_args
+ assert isinstance(output, _EchoOutput)
+ assert unit is None
+ assert ctx.workspace_id == 7
+
+
+@pytest.mark.asyncio
+async def test_async_mode_returns_202_and_pending_run(monkeypatch):
+ """``?mode=async`` inserts a pending run and returns its id without blocking."""
+ from unittest.mock import AsyncMock
+
+ from app.capabilities.core.access import rest
+
+ monkeypatch.setattr(
+ rest, "create_pending_run", AsyncMock(return_value="async-id"), raising=True
+ )
+ # Don't actually run the scrape in the background during this unit test.
+ monkeypatch.setattr(rest, "_execute_async_run", AsyncMock(), raising=True)
+
+ app = _build_app([_ECHO], monkeypatch)
+ async with _client(app) as client:
+ resp = await client.post(
+ "/api/v1/workspaces/7/scrapers/test/echo?mode=async",
+ json={"value": "hi"},
+ )
+ assert resp.status_code == 202
+ assert resp.json() == {"run_id": "run_async-id", "status": "running"}
+
+
+@pytest.mark.asyncio
+async def test_run_events_replays_buffer_then_finishes(monkeypatch):
+ """The SSE endpoint replays buffered events and closes on ``run.finished``."""
+ from app.capabilities.core.events import run_event_bus
+
+ row = _fake_run_row(status="running")
+ raw = str(row.id)
+ run_event_bus.publish(
+ raw, {"type": "run.progress", "phase": "scraping", "current": 1}
+ )
+ run_event_bus.publish(
+ raw, {"type": "run.finished", "status": "success", "item_count": 2}
+ )
+
+ app = _build_app_with_rows(monkeypatch, [row])
+ try:
+ async with _client(app) as client:
+ resp = await client.get(
+ f"/api/v1/workspaces/7/scrapers/runs/run_{raw}/events"
+ )
+ assert resp.status_code == 200
+ body = resp.text
+ assert '"type": "run.progress"' in body
+ assert '"type": "run.finished"' in body
+ assert '"status": "success"' in body
+ finally:
+ run_event_bus.close(raw)
+
+
+@pytest.mark.asyncio
+async def test_cancel_finalizes_running_run(monkeypatch):
+ """Cancel signals the task, finalizes as ``cancelled``, and emits a terminal."""
+ import asyncio
+ from unittest.mock import AsyncMock
+
+ from app.capabilities.core.access import rest
+ from app.capabilities.core.events import run_event_bus
+
+ finalize = AsyncMock(return_value=True)
+ monkeypatch.setattr(rest, "finalize_run", finalize, raising=True)
+
+ row = _fake_run_row(status="running")
+ raw = str(row.id)
+ task = asyncio.create_task(asyncio.sleep(60))
+ run_event_bus.register_task(raw, task)
+
+ app = _build_app_with_rows(monkeypatch, [row])
+ try:
+ async with _client(app) as client:
+ resp = await client.post(
+ f"/api/v1/workspaces/7/scrapers/runs/run_{raw}/cancel"
+ )
+ assert resp.status_code == 200
+ assert resp.json() == {"run_id": f"run_{raw}", "status": "cancelled"}
+ finalize.assert_awaited_once()
+ assert finalize.await_args.kwargs["status"] == "cancelled"
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ run_event_bus.close(raw)
+
+
+@pytest.mark.asyncio
+async def test_cancel_conflicts_when_not_running(monkeypatch):
+ """Cancelling a terminal run is a 409, not a silent overwrite."""
+ row = _fake_run_row(status="success")
+ app = _build_app_with_rows(monkeypatch, [row])
+ async with _client(app) as client:
+ resp = await client.post(
+ f"/api/v1/workspaces/7/scrapers/runs/run_{row.id}/cancel"
+ )
+ assert resp.status_code == 409
+
+
+def test_emit_progress_is_a_noop_without_context():
+ """Scraper code can call ``emit_progress`` freely; unset context = no-op."""
+ from app.capabilities.core.progress import emit_progress, progress_scope
+
+ # No active reporter -> returns without raising, records nothing.
+ emit_progress("phase", "message", current=1, total=2, unit="item")
+
+ # Inside a scope, coarse events are buffered for persistence.
+ with progress_scope() as reporter:
+ emit_progress("starting", "go")
+ emit_progress("done", current=5, unit="item")
+ assert len(reporter.coarse) == 2
+ assert reporter.coarse[0]["phase"] == "starting"
diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/shared/__init__.py b/surfsense_backend/tests/unit/capabilities/google_maps/__init__.py
similarity index 100%
rename from surfsense_backend/app/tasks/chat/streaming/handlers/tools/scrape_webpage/shared/__init__.py
rename to surfsense_backend/tests/unit/capabilities/google_maps/__init__.py
diff --git a/surfsense_backend/tests/unit/capabilities/google_maps/reviews/__init__.py b/surfsense_backend/tests/unit/capabilities/google_maps/reviews/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/google_maps/reviews/test_executor.py b/surfsense_backend/tests/unit/capabilities/google_maps/reviews/test_executor.py
new file mode 100644
index 000000000..912f191df
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/google_maps/reviews/test_executor.py
@@ -0,0 +1,87 @@
+"""``google_maps.reviews`` executor: verb input → actor input mapping → typed items.
+
+Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
+own payload→GoogleMapsReviewsInput mapping and the dict→ReviewItem wrapping.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities.google_maps.reviews.executor import build_reviews_executor
+from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOutput
+from app.exceptions import ForbiddenError
+from app.proprietary.platforms.google_maps import GoogleMapsReviewsInput
+from app.proprietary.platforms.google_maps.scraper import SignInRequiredError
+
+pytestmark = pytest.mark.unit
+
+
+class _FakeScraper:
+ def __init__(self, items: list[dict]):
+ self._items = items
+ self.calls: list[GoogleMapsReviewsInput] = []
+
+ async def __call__(self, actor_input: GoogleMapsReviewsInput) -> list[dict]:
+ self.calls.append(actor_input)
+ return self._items
+
+
+async def test_maps_urls_to_start_urls_and_wraps_items():
+ scraper = _FakeScraper([{"text": "Great place", "stars": 5.0}])
+ execute = build_reviews_executor(scrape_fn=scraper)
+
+ out = await execute(ReviewsInput(urls=["https://www.google.com/maps/place/x"]))
+
+ assert isinstance(out, ReviewsOutput)
+ assert len(out.items) == 1
+ assert out.items[0].text == "Great place"
+ assert out.items[0].stars == 5.0
+
+ (actor_input,) = scraper.calls
+ assert [u.url for u in actor_input.startUrls] == [
+ "https://www.google.com/maps/place/x"
+ ]
+
+
+async def test_forwards_place_ids_and_options():
+ scraper = _FakeScraper([])
+ execute = build_reviews_executor(scrape_fn=scraper)
+
+ await execute(
+ ReviewsInput(
+ place_ids=["ChIJx"],
+ max_reviews=50,
+ sort_by="highestRanking",
+ language="fr",
+ start_date="2024-01-01",
+ )
+ )
+
+ (actor_input,) = scraper.calls
+ assert actor_input.placeIds == ["ChIJx"]
+ assert actor_input.maxReviews == 50
+ assert actor_input.reviewsSort == "highestRanking"
+ assert actor_input.language == "fr"
+ assert actor_input.reviewsStartDate == "2024-01-01"
+
+
+async def test_sign_in_required_maps_to_forbidden_403():
+ async def _raise(_actor_input):
+ raise SignInRequiredError("wall hit")
+
+ execute = build_reviews_executor(scrape_fn=_raise)
+
+ with pytest.raises(ForbiddenError) as exc_info:
+ await execute(ReviewsInput(place_ids=["ChIJx"]))
+ assert exc_info.value.status_code == 403
+
+
+async def test_other_faults_propagate_for_the_door_to_map():
+ async def _boom(_actor_input):
+ raise RuntimeError("proxy exploded")
+
+ execute = build_reviews_executor(scrape_fn=_boom)
+
+ with pytest.raises(RuntimeError):
+ await execute(ReviewsInput(place_ids=["ChIJx"]))
diff --git a/surfsense_backend/tests/unit/capabilities/google_maps/reviews/test_schemas.py b/surfsense_backend/tests/unit/capabilities/google_maps/reviews/test_schemas.py
new file mode 100644
index 000000000..9381161d0
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/google_maps/reviews/test_schemas.py
@@ -0,0 +1,35 @@
+"""``google_maps.reviews`` input guards: a source is required and the batch is bounded."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from app.capabilities.google_maps.reviews.schemas import (
+ MAX_MAPS_REVIEW_SOURCES,
+ ReviewsInput,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_rejects_input_with_no_source():
+ with pytest.raises(ValidationError):
+ ReviewsInput()
+
+
+def test_accepts_urls_or_place_ids():
+ assert ReviewsInput(urls=["https://maps.google.com/x"]).place_ids == []
+ assert ReviewsInput(place_ids=["ChIJx"]).urls == []
+
+
+def test_max_reviews_defaults_and_is_bounded():
+ assert ReviewsInput(place_ids=["ChIJx"]).max_reviews == 20
+ with pytest.raises(ValidationError):
+ ReviewsInput(place_ids=["ChIJx"], max_reviews=0)
+
+
+def test_rejects_more_sources_than_the_cap():
+ too_many = [f"ChIJ{i}" for i in range(MAX_MAPS_REVIEW_SOURCES + 1)]
+ with pytest.raises(ValidationError):
+ ReviewsInput(place_ids=too_many)
diff --git a/surfsense_backend/tests/unit/capabilities/google_maps/scrape/__init__.py b/surfsense_backend/tests/unit/capabilities/google_maps/scrape/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/google_maps/scrape/test_executor.py b/surfsense_backend/tests/unit/capabilities/google_maps/scrape/test_executor.py
new file mode 100644
index 000000000..aaa3af297
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/google_maps/scrape/test_executor.py
@@ -0,0 +1,115 @@
+"""``google_maps.scrape`` executor: verb input → actor input mapping → typed items.
+
+Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
+own payload→GoogleMapsScrapeInput mapping and the dict→PlaceItem wrapping.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities.google_maps.scrape.executor import build_scrape_executor
+from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutput
+from app.exceptions import ForbiddenError
+from app.proprietary.platforms.google_maps import GoogleMapsScrapeInput
+from app.proprietary.platforms.google_maps.scraper import SignInRequiredError
+
+pytestmark = pytest.mark.unit
+
+
+class _FakeScraper:
+ """Records the actor input it was called with and returns canned items."""
+
+ def __init__(self, items: list[dict]):
+ self._items = items
+ self.calls: list[GoogleMapsScrapeInput] = []
+
+ async def __call__(self, actor_input: GoogleMapsScrapeInput) -> list[dict]:
+ self.calls.append(actor_input)
+ return self._items
+
+
+async def test_maps_queries_and_wraps_items():
+ scraper = _FakeScraper([{"title": "Blue Bottle", "placeId": "abc"}])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ out = await execute(ScrapeInput(search_queries=["coffee"], location="Austin"))
+
+ assert isinstance(out, ScrapeOutput)
+ assert len(out.items) == 1
+ assert out.items[0].title == "Blue Bottle"
+ assert out.items[0].placeId == "abc"
+
+ (actor_input,) = scraper.calls
+ assert actor_input.searchStringsArray == ["coffee"]
+ assert actor_input.locationQuery == "Austin"
+
+
+async def test_maps_urls_and_place_ids():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(
+ ScrapeInput(
+ urls=["https://www.google.com/maps/place/x"],
+ place_ids=["ChIJxxxx"],
+ )
+ )
+
+ (actor_input,) = scraper.calls
+ assert [u.url for u in actor_input.startUrls] == [
+ "https://www.google.com/maps/place/x"
+ ]
+ assert actor_input.placeIds == ["ChIJxxxx"]
+
+
+async def test_max_places_maps_to_per_search_cap():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(ScrapeInput(search_queries=["x"], max_places=25))
+
+ (actor_input,) = scraper.calls
+ assert actor_input.maxCrawledPlacesPerSearch == 25
+
+
+async def test_forwards_detail_review_and_image_options():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(
+ ScrapeInput(
+ search_queries=["x"],
+ include_details=True,
+ max_reviews=5,
+ max_images=3,
+ language="fr",
+ )
+ )
+
+ (actor_input,) = scraper.calls
+ assert actor_input.scrapePlaceDetailPage is True
+ assert actor_input.maxReviews == 5
+ assert actor_input.maxImages == 3
+ assert actor_input.language == "fr"
+
+
+async def test_sign_in_required_maps_to_forbidden_403():
+ async def _raise(_actor_input):
+ raise SignInRequiredError("wall hit")
+
+ execute = build_scrape_executor(scrape_fn=_raise)
+
+ with pytest.raises(ForbiddenError) as exc_info:
+ await execute(ScrapeInput(search_queries=["x"]))
+ assert exc_info.value.status_code == 403
+
+
+async def test_other_faults_propagate_for_the_door_to_map():
+ async def _boom(_actor_input):
+ raise RuntimeError("proxy exploded")
+
+ execute = build_scrape_executor(scrape_fn=_boom)
+
+ with pytest.raises(RuntimeError):
+ await execute(ScrapeInput(search_queries=["x"]))
diff --git a/surfsense_backend/tests/unit/capabilities/google_maps/scrape/test_schemas.py b/surfsense_backend/tests/unit/capabilities/google_maps/scrape/test_schemas.py
new file mode 100644
index 000000000..fe164a98a
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/google_maps/scrape/test_schemas.py
@@ -0,0 +1,36 @@
+"""``google_maps.scrape`` input guards: a source is required and the batch is bounded."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from app.capabilities.google_maps.scrape.schemas import (
+ MAX_MAPS_SOURCES,
+ ScrapeInput,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_rejects_input_with_no_source():
+ with pytest.raises(ValidationError):
+ ScrapeInput()
+
+
+def test_accepts_any_single_source():
+ assert ScrapeInput(search_queries=["coffee"]).urls == []
+ assert ScrapeInput(urls=["https://maps.google.com/x"]).place_ids == []
+ assert ScrapeInput(place_ids=["ChIJx"]).search_queries == []
+
+
+def test_max_places_defaults_and_is_bounded():
+ assert ScrapeInput(search_queries=["x"]).max_places == 10
+ with pytest.raises(ValidationError):
+ ScrapeInput(search_queries=["x"], max_places=0)
+
+
+def test_rejects_more_sources_than_the_cap():
+ too_many = [f"q{i}" for i in range(MAX_MAPS_SOURCES + 1)]
+ with pytest.raises(ValidationError):
+ ScrapeInput(search_queries=too_many)
diff --git a/surfsense_backend/tests/unit/capabilities/google_maps/test_registry.py b/surfsense_backend/tests/unit/capabilities/google_maps/test_registry.py
new file mode 100644
index 000000000..c6a007645
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/google_maps/test_registry.py
@@ -0,0 +1,32 @@
+"""The google_maps namespace registers each verb as one Capability the doors/agent read."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities import (
+ google_maps, # noqa: F401 — importing the namespace registers its verbs
+)
+from app.capabilities.core.store import get_capability
+from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOutput
+from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutput
+
+pytestmark = pytest.mark.unit
+
+
+def test_google_maps_scrape_is_registered_and_free():
+ cap = get_capability("google_maps.scrape")
+
+ assert cap.name == "google_maps.scrape"
+ assert cap.input_schema is ScrapeInput
+ assert cap.output_schema is ScrapeOutput
+ assert cap.billing_unit is None
+
+
+def test_google_maps_reviews_is_registered_and_free():
+ cap = get_capability("google_maps.reviews")
+
+ assert cap.name == "google_maps.reviews"
+ assert cap.input_schema is ReviewsInput
+ assert cap.output_schema is ReviewsOutput
+ assert cap.billing_unit is None
diff --git a/surfsense_backend/tests/unit/capabilities/reddit/__init__.py b/surfsense_backend/tests/unit/capabilities/reddit/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/reddit/scrape/__init__.py b/surfsense_backend/tests/unit/capabilities/reddit/scrape/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/reddit/scrape/test_executor.py b/surfsense_backend/tests/unit/capabilities/reddit/scrape/test_executor.py
new file mode 100644
index 000000000..257b974a0
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/reddit/scrape/test_executor.py
@@ -0,0 +1,98 @@
+"""``reddit.scrape`` executor: verb input → actor input mapping → typed items.
+
+Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
+own payload→RedditScrapeInput mapping and the dict→RedditItem wrapping.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities.reddit.scrape.executor import build_scrape_executor
+from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
+from app.exceptions import ForbiddenError
+from app.proprietary.platforms.reddit import RedditAccessBlockedError, RedditScrapeInput
+
+pytestmark = pytest.mark.unit
+
+
+class _FakeScraper:
+ """Records the actor input + limit it was called with; returns canned items."""
+
+ def __init__(self, items: list[dict]):
+ self._items = items
+ self.calls: list[tuple[RedditScrapeInput, int | None]] = []
+
+ async def __call__(
+ self, actor_input: RedditScrapeInput, *, limit: int | None = None
+ ) -> list[dict]:
+ self.calls.append((actor_input, limit))
+ return self._items
+
+
+async def test_maps_urls_to_start_urls_and_wraps_items():
+ scraper = _FakeScraper([{"dataType": "post", "id": "abc", "title": "Hello"}])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ out = await execute(ScrapeInput(urls=["https://www.reddit.com/r/python/"]))
+
+ assert isinstance(out, ScrapeOutput)
+ assert len(out.items) == 1
+ assert out.items[0].id == "abc"
+ assert out.items[0].title == "Hello"
+ assert out.items[0].dataType == "post"
+
+ (actor_input, _limit) = scraper.calls[0]
+ assert [u.url for u in actor_input.startUrls] == [
+ "https://www.reddit.com/r/python/"
+ ]
+ assert actor_input.searches == []
+
+
+async def test_forwards_search_queries_and_community():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(ScrapeInput(search_queries=["a", "b"], community="python"))
+
+ (actor_input, _limit) = scraper.calls[0]
+ assert actor_input.searches == ["a", "b"]
+ assert actor_input.searchCommunityName == "python"
+ assert actor_input.startUrls == []
+
+
+async def test_maps_caps_and_passes_limit():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(
+ ScrapeInput(
+ search_queries=["x"],
+ max_items=25,
+ max_posts=7,
+ max_comments=3,
+ skip_comments=True,
+ sort="top",
+ time_filter="week",
+ )
+ )
+
+ (actor_input, limit) = scraper.calls[0]
+ assert actor_input.maxItems == 25
+ assert actor_input.maxPostCount == 7
+ assert actor_input.maxComments == 3
+ assert actor_input.skipComments is True
+ assert actor_input.sort == "top"
+ assert actor_input.time == "week"
+ # The outer collection limit is the caller's total-item cap.
+ assert limit == 25
+
+
+async def test_access_blocked_maps_to_forbidden():
+ async def _blocked(actor_input: RedditScrapeInput, *, limit: int | None = None):
+ raise RedditAccessBlockedError("all IPs refused")
+
+ execute = build_scrape_executor(scrape_fn=_blocked)
+
+ with pytest.raises(ForbiddenError):
+ await execute(ScrapeInput(search_queries=["x"]))
diff --git a/surfsense_backend/tests/unit/capabilities/reddit/scrape/test_schemas.py b/surfsense_backend/tests/unit/capabilities/reddit/scrape/test_schemas.py
new file mode 100644
index 000000000..e4fb4a4a8
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/reddit/scrape/test_schemas.py
@@ -0,0 +1,51 @@
+"""``reddit.scrape`` input guards: a source is required and the batch is bounded."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from app.capabilities.reddit.scrape.schemas import (
+ MAX_REDDIT_ITEMS,
+ MAX_REDDIT_SOURCES,
+ ScrapeInput,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_rejects_input_with_no_source():
+ with pytest.raises(ValidationError):
+ ScrapeInput()
+
+
+def test_accepts_urls_only():
+ payload = ScrapeInput(urls=["https://www.reddit.com/r/python/"])
+ assert payload.search_queries == []
+
+
+def test_accepts_search_queries_only():
+ payload = ScrapeInput(search_queries=["notebooklm alternative"])
+ assert payload.urls == []
+
+
+def test_accepts_community_only():
+ payload = ScrapeInput(community="python")
+ assert payload.community == "python"
+
+
+def test_defaults_and_bounds():
+ payload = ScrapeInput(search_queries=["x"])
+ assert payload.max_items == 10
+ assert payload.sort == "new"
+ assert payload.include_nsfw is True
+ with pytest.raises(ValidationError):
+ ScrapeInput(search_queries=["x"], max_items=0)
+ with pytest.raises(ValidationError):
+ ScrapeInput(search_queries=["x"], max_items=MAX_REDDIT_ITEMS + 1)
+
+
+def test_rejects_more_sources_than_the_cap():
+ too_many = [f"https://redd.it/{i}" for i in range(MAX_REDDIT_SOURCES + 1)]
+ with pytest.raises(ValidationError):
+ ScrapeInput(urls=too_many)
diff --git a/surfsense_backend/tests/unit/capabilities/reddit/test_registry.py b/surfsense_backend/tests/unit/capabilities/reddit/test_registry.py
new file mode 100644
index 000000000..e0bca5f26
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/reddit/test_registry.py
@@ -0,0 +1,22 @@
+"""The reddit namespace registers its verb as one Capability the doors/agent read."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities import (
+ reddit, # noqa: F401 — importing the namespace registers its verbs
+)
+from app.capabilities.core.store import get_capability
+from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
+
+pytestmark = pytest.mark.unit
+
+
+def test_reddit_scrape_is_registered_and_free():
+ cap = get_capability("reddit.scrape")
+
+ assert cap.name == "reddit.scrape"
+ assert cap.input_schema is ScrapeInput
+ assert cap.output_schema is ScrapeOutput
+ assert cap.billing_unit is None
diff --git a/surfsense_backend/tests/unit/capabilities/test_billing.py b/surfsense_backend/tests/unit/capabilities/test_billing.py
new file mode 100644
index 000000000..36d9a2978
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/test_billing.py
@@ -0,0 +1,429 @@
+"""Billing charges the workspace owner once per billable success at the executor (03c).
+
+Boundaries mocked: the DB session and the audit helper. NOT mocked: the real
+WebCrawlCreditService debit math and the owner-billed decision.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock
+from uuid import UUID
+
+import pytest
+
+import app.capabilities.core.billing as billing
+from app.capabilities.core.billing import charge_capability, gate_capability
+from app.capabilities.core.types import BillingUnit, CapabilityContext
+from app.capabilities.web.crawl.schemas import CrawlInput, CrawlItem, CrawlOutput
+from app.config import config
+from app.services.web_crawl_credit_service import InsufficientCreditsError
+
+pytestmark = pytest.mark.unit
+
+_WORKSPACE_ID = 1
+_OWNER = UUID("00000000-0000-0000-0000-0000000000bb")
+
+
+class _FakeUser:
+ def __init__(self, balance_micros: int, reserved_micros: int = 0):
+ self.credit_micros_balance = balance_micros
+ self.credit_micros_reserved = reserved_micros
+
+
+def _make_session(owner_id, balance_micros):
+ """Mock session serving owner-resolution and the charge_credits debit."""
+ fake_user = _FakeUser(balance_micros)
+ session = AsyncMock()
+ session.add = MagicMock()
+
+ def _make_result(*_args, **_kwargs):
+ result = MagicMock()
+ result.scalar_one_or_none.return_value = owner_id # owner resolution
+ result.unique.return_value.scalar_one_or_none.return_value = fake_user # debit
+ return result
+
+ session.execute = AsyncMock(side_effect=_make_result)
+ return session, fake_user
+
+
+def _output(*statuses: str) -> CrawlOutput:
+ return CrawlOutput(
+ items=[
+ CrawlItem(url=f"https://{i}.com", status=status)
+ for i, status in enumerate(statuses)
+ ]
+ )
+
+
+def _ctx(session) -> CapabilityContext:
+ return CapabilityContext(session=session, workspace_id=_WORKSPACE_ID)
+
+
+@pytest.fixture(autouse=True)
+def _stub_auto_reload(monkeypatch):
+ import app.services.auto_reload_service as ar
+
+ monkeypatch.setattr(ar, "maybe_trigger_auto_reload", AsyncMock())
+
+
+@pytest.fixture
+def record_usage(monkeypatch):
+ rec = AsyncMock(return_value=MagicMock())
+ monkeypatch.setattr(billing, "record_token_usage", rec)
+ return rec
+
+
+async def test_charges_workspace_owner_per_successful_crawl(monkeypatch, record_usage):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000)
+ session, user = _make_session(_OWNER, balance_micros=100_000)
+
+ await charge_capability(
+ _output("success", "empty", "success"), BillingUnit.WEB_CRAWL, _ctx(session)
+ )
+
+ # Owner debited 2 * 1000; one web_crawl audit row billed to the OWNER.
+ assert user.credit_micros_balance == 100_000 - 2000
+ record_usage.assert_awaited_once()
+ kwargs = record_usage.await_args.kwargs
+ assert kwargs["usage_type"] == "web_crawl"
+ assert kwargs["user_id"] == _OWNER
+ assert kwargs["workspace_id"] == _WORKSPACE_ID
+ assert kwargs["cost_micros"] == 2000
+
+
+def _output_with_captcha(*statuses: str, attempts: int, solved: int) -> CrawlOutput:
+ out = _output(*statuses)
+ out.captcha_attempts = attempts
+ out.captcha_solved = solved
+ return out
+
+
+async def test_charges_workspace_owner_per_captcha_attempt_even_when_crawl_failed(
+ monkeypatch, record_usage
+):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE", 3000)
+ session, user = _make_session(_OWNER, balance_micros=100_000)
+
+ # Crawl failed (no billable success) but the solver ran twice — attempts bill.
+ await charge_capability(
+ _output_with_captcha("failed", attempts=2, solved=1),
+ BillingUnit.WEB_CRAWL,
+ _ctx(session),
+ )
+
+ assert user.credit_micros_balance == 100_000 - 2 * 3000
+ record_usage.assert_awaited_once()
+ kwargs = record_usage.await_args.kwargs
+ assert kwargs["usage_type"] == "web_crawl_captcha"
+ assert kwargs["user_id"] == _OWNER
+ assert kwargs["cost_micros"] == 6000
+
+
+async def test_captcha_billing_disabled_does_not_charge_attempts(
+ monkeypatch, record_usage
+):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_BILLING_ENABLED", False)
+ session, user = _make_session(_OWNER, balance_micros=100_000)
+
+ await charge_capability(
+ _output_with_captcha("failed", attempts=2, solved=1),
+ BillingUnit.WEB_CRAWL,
+ _ctx(session),
+ )
+
+ record_usage.assert_not_awaited()
+ assert user.credit_micros_balance == 100_000
+
+
+async def test_no_successful_rows_is_free(monkeypatch, record_usage):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ session, user = _make_session(_OWNER, balance_micros=100_000)
+
+ await charge_capability(
+ _output("empty", "failed"), BillingUnit.WEB_CRAWL, _ctx(session)
+ )
+
+ record_usage.assert_not_awaited()
+ assert user.credit_micros_balance == 100_000
+
+
+async def test_disabled_is_noop(monkeypatch, record_usage):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False)
+ session, user = _make_session(_OWNER, balance_micros=100_000)
+
+ await charge_capability(
+ _output("success", "success"), BillingUnit.WEB_CRAWL, _ctx(session)
+ )
+
+ record_usage.assert_not_awaited()
+ session.execute.assert_not_called()
+ assert user.credit_micros_balance == 100_000
+
+
+async def test_free_verb_without_a_unit_is_noop(monkeypatch, record_usage):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ session, user = _make_session(_OWNER, balance_micros=100_000)
+
+ await charge_capability(_output("success", "success"), None, _ctx(session))
+
+ record_usage.assert_not_awaited()
+ session.execute.assert_not_called()
+ assert user.credit_micros_balance == 100_000
+
+
+def _gate_session(owner_id, balance_micros):
+ """Mock session serving owner-resolution and the spendable-balance read."""
+ session = AsyncMock()
+
+ def _make_result(*_args, **_kwargs):
+ result = MagicMock()
+ result.scalar_one_or_none.return_value = owner_id # owner resolution
+ result.first.return_value = (balance_micros, 0) # balance, reserved
+ return result
+
+ session.execute = AsyncMock(side_effect=_make_result)
+ return session
+
+
+async def test_gate_blocks_when_worst_case_exceeds_balance(monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000)
+ session = _gate_session(_OWNER, balance_micros=1500) # affords 1 crawl, not 2
+
+ with pytest.raises(InsufficientCreditsError):
+ await gate_capability(
+ CrawlInput(startUrls=["https://a.com", "https://b.com"]),
+ BillingUnit.WEB_CRAWL,
+ _ctx(session),
+ )
+
+
+async def test_gate_passes_when_balance_covers_worst_case(monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000)
+ session = _gate_session(_OWNER, balance_micros=100_000)
+
+ await gate_capability(
+ CrawlInput(startUrls=["https://a.com", "https://b.com"]),
+ BillingUnit.WEB_CRAWL,
+ _ctx(session),
+ )
+
+
+async def test_gate_is_noop_when_disabled(monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False)
+ session = _gate_session(_OWNER, balance_micros=0)
+
+ await gate_capability(
+ CrawlInput(startUrls=["https://a.com"]), BillingUnit.WEB_CRAWL, _ctx(session)
+ )
+
+
+async def test_gate_reserves_worst_case_captcha_when_solving_enabled(monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False)
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE", 3000)
+ monkeypatch.setattr(config, "CAPTCHA_MAX_ATTEMPTS_PER_URL", 3)
+ monkeypatch.setattr(billing, "captcha_enabled", lambda: True)
+ session = _gate_session(_OWNER, balance_micros=5000) # < 1 url * 3 * 3000
+
+ with pytest.raises(InsufficientCreditsError):
+ await gate_capability(
+ CrawlInput(startUrls=["https://a.com"]),
+ BillingUnit.WEB_CRAWL,
+ _ctx(session),
+ )
+
+
+async def test_gate_does_not_reserve_captcha_when_solving_disabled(monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False)
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_BILLING_ENABLED", True)
+ monkeypatch.setattr(billing, "captcha_enabled", lambda: False)
+ session = _gate_session(_OWNER, balance_micros=0)
+
+ # Solving off → attempts can never happen → nothing to reserve → passes.
+ await gate_capability(
+ CrawlInput(startUrls=["https://a.com"]), BillingUnit.WEB_CRAWL, _ctx(session)
+ )
+
+
+async def test_gate_is_noop_for_free_verb(monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ session = _gate_session(_OWNER, balance_micros=0)
+
+ await gate_capability(CrawlInput(startUrls=["https://a.com"]), None, _ctx(session))
+
+ session.execute.assert_not_called()
+
+
+# ===================================================================
+# Platform scraper per-item billing (Reddit / Search / Maps / YouTube)
+# ===================================================================
+
+
+class _FakePlatformOutput:
+ """Stand-in for a verb output: only the billing-read properties matter."""
+
+ def __init__(self, items: int, attached_review_count: int = 0):
+ self._items = items
+ self._reviews = attached_review_count
+
+ @property
+ def billable_units(self) -> int:
+ return self._items
+
+ @property
+ def attached_review_count(self) -> int:
+ return self._reviews
+
+
+class _FakePlatformInput:
+ """Stand-in for a verb input reporting its worst-case unit counts."""
+
+ def __init__(self, estimated_units: int, estimated_review_units: int = 0):
+ self._units = estimated_units
+ self._review_units = estimated_review_units
+
+ @property
+ def estimated_units(self) -> int:
+ return self._units
+
+ @property
+ def estimated_review_units(self) -> int:
+ return self._review_units
+
+
+@pytest.fixture
+def _enable_platform_billing(monkeypatch):
+ monkeypatch.setattr(config, "PLATFORM_SCRAPE_BILLING_ENABLED", True)
+
+
+async def test_platform_charges_owner_per_item(
+ monkeypatch, record_usage, _enable_platform_billing
+):
+ monkeypatch.setattr(config, "REDDIT_SCRAPE_MICROS_PER_ITEM", 3500)
+ session, user = _make_session(_OWNER, balance_micros=1_000_000)
+
+ charged = await charge_capability(
+ _FakePlatformOutput(3), BillingUnit.REDDIT_ITEM, _ctx(session)
+ )
+
+ assert charged == 3 * 3500
+ assert user.credit_micros_balance == 1_000_000 - 3 * 3500
+ record_usage.assert_awaited_once()
+ kwargs = record_usage.await_args.kwargs
+ assert kwargs["usage_type"] == "reddit_item"
+ assert kwargs["user_id"] == _OWNER
+ assert kwargs["workspace_id"] == _WORKSPACE_ID
+ assert kwargs["cost_micros"] == 3 * 3500
+
+
+async def test_platform_maps_scrape_dual_meters_places_and_reviews(
+ monkeypatch, record_usage, _enable_platform_billing
+):
+ monkeypatch.setattr(config, "GOOGLE_MAPS_MICROS_PER_PLACE", 5000)
+ monkeypatch.setattr(config, "GOOGLE_MAPS_MICROS_PER_REVIEW", 2000)
+ session, user = _make_session(_OWNER, balance_micros=1_000_000)
+
+ # 2 places + 10 attached reviews -> 2*5000 + 10*2000 = 30000.
+ charged = await charge_capability(
+ _FakePlatformOutput(2, attached_review_count=10),
+ BillingUnit.GOOGLE_MAPS_PLACE,
+ _ctx(session),
+ )
+
+ assert charged == 2 * 5000 + 10 * 2000
+ assert user.credit_micros_balance == 1_000_000 - 30_000
+ assert record_usage.await_count == 2
+ usage_types = {c.kwargs["usage_type"] for c in record_usage.await_args_list}
+ assert usage_types == {"google_maps_place", "google_maps_review"}
+
+
+async def test_platform_charge_disabled_is_noop(monkeypatch, record_usage):
+ monkeypatch.setattr(config, "PLATFORM_SCRAPE_BILLING_ENABLED", False)
+ monkeypatch.setattr(config, "REDDIT_SCRAPE_MICROS_PER_ITEM", 3500)
+ session, user = _make_session(_OWNER, balance_micros=1_000_000)
+
+ charged = await charge_capability(
+ _FakePlatformOutput(3), BillingUnit.REDDIT_ITEM, _ctx(session)
+ )
+
+ assert charged == 0
+ record_usage.assert_not_awaited()
+ session.execute.assert_not_called()
+ assert user.credit_micros_balance == 1_000_000
+
+
+async def test_platform_no_items_is_free(
+ monkeypatch, record_usage, _enable_platform_billing
+):
+ monkeypatch.setattr(config, "YOUTUBE_MICROS_PER_COMMENT", 3500)
+ session, user = _make_session(_OWNER, balance_micros=1_000_000)
+
+ charged = await charge_capability(
+ _FakePlatformOutput(0), BillingUnit.YOUTUBE_COMMENT, _ctx(session)
+ )
+
+ assert charged == 0
+ record_usage.assert_not_awaited()
+ assert user.credit_micros_balance == 1_000_000
+
+
+async def test_platform_gate_blocks_when_worst_case_exceeds_balance(
+ monkeypatch, _enable_platform_billing
+):
+ monkeypatch.setattr(config, "GOOGLE_SEARCH_MICROS_PER_SERP", 5500)
+ session = _gate_session(_OWNER, balance_micros=6000) # affords 1 SERP, not 2
+
+ with pytest.raises(InsufficientCreditsError):
+ await gate_capability(
+ _FakePlatformInput(estimated_units=2),
+ BillingUnit.GOOGLE_SEARCH_SERP,
+ _ctx(session),
+ )
+
+
+async def test_platform_gate_maps_reserves_places_plus_reviews(
+ monkeypatch, _enable_platform_billing
+):
+ monkeypatch.setattr(config, "GOOGLE_MAPS_MICROS_PER_PLACE", 5000)
+ monkeypatch.setattr(config, "GOOGLE_MAPS_MICROS_PER_REVIEW", 2000)
+ # 1 place (5000) + 10 worst-case reviews (20000) = 25000 required.
+ session = _gate_session(_OWNER, balance_micros=20_000)
+
+ with pytest.raises(InsufficientCreditsError):
+ await gate_capability(
+ _FakePlatformInput(estimated_units=1, estimated_review_units=10),
+ BillingUnit.GOOGLE_MAPS_PLACE,
+ _ctx(session),
+ )
+
+
+async def test_platform_gate_passes_when_affordable(
+ monkeypatch, _enable_platform_billing
+):
+ monkeypatch.setattr(config, "GOOGLE_SEARCH_MICROS_PER_SERP", 5500)
+ session = _gate_session(_OWNER, balance_micros=1_000_000)
+
+ await gate_capability(
+ _FakePlatformInput(estimated_units=2),
+ BillingUnit.GOOGLE_SEARCH_SERP,
+ _ctx(session),
+ )
+
+
+async def test_platform_gate_disabled_is_noop(monkeypatch):
+ monkeypatch.setattr(config, "PLATFORM_SCRAPE_BILLING_ENABLED", False)
+ session = _gate_session(_OWNER, balance_micros=0)
+
+ await gate_capability(
+ _FakePlatformInput(estimated_units=1000),
+ BillingUnit.REDDIT_ITEM,
+ _ctx(session),
+ )
+
+ session.execute.assert_not_called()
diff --git a/surfsense_backend/tests/unit/capabilities/test_registry.py b/surfsense_backend/tests/unit/capabilities/test_registry.py
new file mode 100644
index 000000000..ff8f6d931
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/test_registry.py
@@ -0,0 +1,23 @@
+"""The registry exposes each verb as one Capability entry the doors/agent read from."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities import (
+ web, # noqa: F401 — importing the namespace registers its verbs
+)
+from app.capabilities.core.store import get_capability
+from app.capabilities.core.types import BillingUnit
+from app.capabilities.web.crawl.schemas import CrawlInput, CrawlOutput
+
+pytestmark = pytest.mark.unit
+
+
+def test_web_crawl_is_registered_with_its_schemas_and_billing_unit():
+ cap = get_capability("web.crawl")
+
+ assert cap.name == "web.crawl"
+ assert cap.input_schema is CrawlInput
+ assert cap.output_schema is CrawlOutput
+ assert cap.billing_unit is BillingUnit.WEB_CRAWL
diff --git a/surfsense_backend/tests/unit/capabilities/test_run_truncation.py b/surfsense_backend/tests/unit/capabilities/test_run_truncation.py
new file mode 100644
index 000000000..5723c2ff2
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/test_run_truncation.py
@@ -0,0 +1,346 @@
+"""Tool-boundary truncation + run read-tool behavior (no DB).
+
+Covers the pure pieces of the DB-backed run log: JSONL serialization, the
+char-budgeted preview (including the single-oversized-item case and the
+storage-failure degrade), and the ``read_run``/``search_run`` tools' paging,
+search, ReDoS fallback, and workspace scoping — all with a fake session so the
+unit suite never touches a database.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+
+import pytest
+from pydantic import BaseModel
+
+from app.agents.chat.multi_agent_chat.subagents.shared import run_reader
+from app.capabilities.core.access.agent import _build_preview
+from app.capabilities.core.runs import (
+ RUN_OUTPUT_CHAR_CAP,
+ SerializedOutput,
+ serialize_output,
+)
+
+pytestmark = pytest.mark.unit
+
+
+class _Item(BaseModel):
+ id: int
+ name: str
+ note: str | None = None
+
+
+class _Output(BaseModel):
+ items: list[_Item]
+
+
+class _Scalar(BaseModel):
+ value: str
+
+
+def test_serialize_output_is_jsonl_and_excludes_none():
+ out = _Output(items=[_Item(id=1, name="a"), _Item(id=2, name="b", note="x")])
+ result = serialize_output(out)
+
+ lines = result.text.split("\n")
+ assert result.item_count == 2
+ assert len(lines) == 2
+ # exclude_none: the first item has no "note" key
+ assert "note" not in json.loads(lines[0])
+ assert json.loads(lines[1])["note"] == "x"
+ assert result.char_count == len(result.text)
+
+
+def test_serialize_output_without_items_is_single_line():
+ result = serialize_output(_Scalar(value="hi"))
+ assert result.item_count == 1
+ assert json.loads(result.text) == {"value": "hi"}
+
+
+def test_preview_is_char_budgeted_and_references_run():
+ # Many small items whose total blows the cap.
+ per_item = "y" * 500
+ items = [f'{{"i": {i}, "v": "{per_item}"}}' for i in range(500)]
+ body = "\n".join(items)
+ serialized = SerializedOutput(
+ text=body, item_count=len(items), char_count=len(body)
+ )
+
+ preview = _build_preview(serialized, run_id="abc")
+
+ assert len(preview) < serialized.char_count
+ assert "run_abc" in preview
+ assert "read_run" in preview
+ # Only a prefix of items is shown.
+ assert preview.count('"i":') < len(items)
+
+
+def test_preview_handles_single_oversized_item():
+ huge = "z" * (RUN_OUTPUT_CHAR_CAP * 2)
+ serialized = SerializedOutput(text=huge, item_count=1, char_count=len(huge))
+
+ preview = _build_preview(serialized, run_id="big")
+
+ # Still returns a clipped head rather than nothing.
+ assert "z" in preview
+ assert "run_big" in preview
+ assert len(preview) < serialized.char_count
+
+
+def test_preview_degrades_when_storage_failed():
+ body = "\n".join(f'{{"i": {i}}}' for i in range(200))
+ serialized = SerializedOutput(text=body, item_count=200, char_count=len(body))
+
+ preview = _build_preview(serialized, run_id=None)
+
+ assert "storage error" in preview
+ assert "run_" not in preview
+
+
+# --- read tools -----------------------------------------------------------
+
+
+class _FakeResult:
+ def __init__(self, value):
+ self._value = value
+
+ def scalar_one_or_none(self):
+ return self._value
+
+
+class _FakeSession:
+ def __init__(self, value, calls):
+ self._value = value
+ self._calls = calls
+
+ async def execute(self, stmt):
+ self._calls.append(str(stmt))
+ return _FakeResult(self._value)
+
+
+def _patch_session(monkeypatch, value, calls):
+ @contextlib.asynccontextmanager
+ async def _maker():
+ yield _FakeSession(value, calls)
+
+ monkeypatch.setattr(run_reader, "shielded_async_session", _maker)
+
+
+def _tools():
+ read_run, search_run, _export_run = run_reader.build_run_reader_tools(
+ workspace_id=7
+ )
+ return read_run, search_run
+
+
+_BODY = "\n".join(f'{{"i": {i}, "name": "item_{i}"}}' for i in range(10))
+
+
+@pytest.mark.asyncio
+async def test_read_run_paginates(monkeypatch):
+ calls: list[str] = []
+ _patch_session(monkeypatch, _BODY, calls)
+ read_run, _ = _tools()
+
+ out = await read_run.ainvoke(
+ {
+ "ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000",
+ "offset": 2,
+ "limit": 3,
+ }
+ )
+ assert "item_2" in out and "item_3" in out and "item_4" in out
+ assert "item_0" not in out and "item_5" not in out
+ # Scoped by workspace_id in the query.
+ assert "workspace_id" in calls[0]
+
+
+@pytest.mark.asyncio
+async def test_read_run_char_offset_pages_inside_one_huge_item(monkeypatch):
+ """A single item bigger than the cap is fully reachable via char_offset."""
+ huge_line = "A" * RUN_OUTPUT_CHAR_CAP + "MARKER" + "B" * 1000
+ _patch_session(monkeypatch, huge_line, [])
+ read_run, _ = _tools()
+ ref = "run_" + "0" * 8 + "-0000-0000-0000-000000000000"
+
+ first = await read_run.ainvoke({"ref": ref, "offset": 0, "limit": 1})
+ assert "MARKER" not in first # clipped at the cap
+ assert f"char_offset={RUN_OUTPUT_CHAR_CAP}" in first # continuation hint
+
+ second = await read_run.ainvoke(
+ {"ref": ref, "offset": 0, "limit": 1, "char_offset": RUN_OUTPUT_CHAR_CAP}
+ )
+ assert "MARKER" in second
+ assert "truncated" not in second # remainder fits
+
+ past_end = await read_run.ainvoke(
+ {"ref": ref, "offset": 0, "limit": 1, "char_offset": len(huge_line) + 5}
+ )
+ assert "No content at char_offset" in past_end
+
+
+@pytest.mark.asyncio
+async def test_search_run_excerpts_huge_matched_line(monkeypatch):
+ """A match inside a huge line returns a window around it, not the whole line."""
+ huge_line = "x" * 100_000 + "NEEDLE" + "y" * 100_000
+ _patch_session(monkeypatch, huge_line, [])
+ _, search_run = _tools()
+
+ out = await search_run.ainvoke(
+ {"ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000", "pattern": "NEEDLE"}
+ )
+ assert "NEEDLE" in out
+ assert "match at char 100000" in out
+ assert len(out) < 2000 # excerpt, not the 200k line
+
+
+@pytest.mark.asyncio
+async def test_read_run_rejects_bad_ref(monkeypatch):
+ _patch_session(monkeypatch, _BODY, [])
+ read_run, _ = _tools()
+ out = await read_run.ainvoke({"ref": "not-a-ref"})
+ assert "not a valid run reference" in out
+
+
+@pytest.mark.asyncio
+async def test_read_run_not_found(monkeypatch):
+ _patch_session(monkeypatch, None, [])
+ read_run, _ = _tools()
+ out = await read_run.ainvoke(
+ {"ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000"}
+ )
+ assert "not found" in out
+
+
+@pytest.mark.asyncio
+async def test_search_run_matches(monkeypatch):
+ _patch_session(monkeypatch, _BODY, [])
+ _, search_run = _tools()
+ out = await search_run.ainvoke(
+ {
+ "ref": "spill_" + "0" * 8 + "-0000-0000-0000-000000000000",
+ "pattern": "item_7",
+ }
+ )
+ assert "item_7" in out
+ assert "item_1" not in out.split("item_7")[0]
+
+
+# --- export_run ------------------------------------------------------------
+
+
+_CRAWL_BODY = "\n".join(
+ [
+ json.dumps(
+ {
+ "url": "https://x.com/team/",
+ "status": "success",
+ "links": [
+ {
+ "url": "https://x.com/author/jane/",
+ "text": "Jane Doe",
+ "context": "Jane Doe General Partner",
+ "kind": "internal",
+ },
+ {
+ "url": "https://x.com/author/bob/",
+ "text": "Bob Roe",
+ "context": "Bob Roe Operations",
+ "kind": "internal",
+ },
+ # Duplicate of Jane (nav + card) — must dedupe.
+ {
+ "url": "https://x.com/author/jane/",
+ "text": "Jane Doe",
+ "context": "Jane Doe General Partner",
+ "kind": "internal",
+ },
+ {
+ "url": "https://x.com/about/",
+ "text": "About",
+ "kind": "internal",
+ },
+ ],
+ }
+ ),
+ json.dumps({"url": "https://x.com/jobs/", "status": "failed", "links": []}),
+ "not json — skipped",
+ ]
+)
+
+
+def test_rows_from_body_links_explode_and_items():
+ links = run_reader._rows_from_body(_CRAWL_BODY, "links")
+ assert len(links) == 4
+ assert links[0]["page"] == "https://x.com/team/"
+ assert links[0]["text"] == "Jane Doe"
+
+ items = run_reader._rows_from_body(_CRAWL_BODY, "items")
+ assert [i["url"] for i in items] == ["https://x.com/team/", "https://x.com/jobs/"]
+
+
+def test_rows_to_csv_dedupes_and_orders_columns():
+ records = run_reader._rows_from_body(_CRAWL_BODY, "links")
+ csv_text, count = run_reader._rows_to_csv(records, ["page", "url", "text"])
+ lines = csv_text.strip().split("\n")
+ assert lines[0] == "page,url,text"
+ assert count == 3 # 4 records - 1 duplicate
+ assert len(lines) == 4 # header + 3 rows
+ assert "Jane Doe" in lines[1]
+
+
+@pytest.mark.asyncio
+async def test_export_run_filters_and_saves(monkeypatch):
+ _patch_session(monkeypatch, _CRAWL_BODY, [])
+ saved: dict = {}
+
+ async def _fake_save(*, virtual_path, content, workspace_id):
+ saved["path"] = virtual_path
+ saved["content"] = content
+ saved["workspace_id"] = workspace_id
+ return 42, "/documents/exports/team.csv"
+
+ monkeypatch.setattr(run_reader, "_save_export_document", _fake_save)
+ _, _, export_run = run_reader.build_run_reader_tools(workspace_id=7)
+
+ out = await export_run.ainvoke(
+ {
+ "ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000",
+ "path": "exports/team.csv",
+ "rows": "links",
+ "include_pattern": "/author/",
+ }
+ )
+ assert "Exported 2 rows" in out # Jane + Bob; About filtered; dupe deduped
+ assert "/documents/exports/team.csv" in out
+ assert "document id 42" in out
+ assert saved["workspace_id"] == 7
+ assert "About" not in saved["content"]
+ assert "Bob Roe" in saved["content"]
+
+
+@pytest.mark.asyncio
+async def test_export_run_empty_filter_is_error(monkeypatch):
+ _patch_session(monkeypatch, _CRAWL_BODY, [])
+ _, _, export_run = run_reader.build_run_reader_tools(workspace_id=7)
+ out = await export_run.ainvoke(
+ {
+ "ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000",
+ "path": "exports/none.csv",
+ "include_pattern": "no-such-thing-anywhere",
+ }
+ )
+ assert out.startswith("Error: no rows to export")
+
+
+@pytest.mark.asyncio
+async def test_search_run_falls_back_on_bad_regex(monkeypatch):
+ _patch_session(monkeypatch, _BODY, [])
+ _, search_run = _tools()
+ # "(" is an invalid regex -> substring fallback, must not raise.
+ out = await search_run.ainvoke(
+ {"ref": "run_" + "0" * 8 + "-0000-0000-0000-000000000000", "pattern": "("}
+ )
+ assert "matched" in out or "No lines" in out
diff --git a/surfsense_backend/tests/unit/capabilities/web/__init__.py b/surfsense_backend/tests/unit/capabilities/web/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/web/crawl/__init__.py b/surfsense_backend/tests/unit/capabilities/web/crawl/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/web/crawl/test_executor.py b/surfsense_backend/tests/unit/capabilities/web/crawl/test_executor.py
new file mode 100644
index 000000000..6294cf4f1
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/web/crawl/test_executor.py
@@ -0,0 +1,166 @@
+"""``web.crawl`` executor behavior: CrawlPage list → typed CrawlOutput items.
+
+Boundary mocked: the crawler engine (fake ``crawl_url`` + link graph). NOT
+mocked: the executor's page→item mapping, truncation, and captcha rollup.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities.web.crawl.executor import build_crawl_executor
+from app.capabilities.web.crawl.schemas import CrawlInput, CrawlOutput
+from app.proprietary.web_crawler import CrawlOutcome, CrawlOutcomeStatus
+
+pytestmark = pytest.mark.unit
+
+_SUCCESS = CrawlOutcomeStatus.SUCCESS
+
+
+class _FakeEngine:
+ def __init__(self, graph: dict[str, tuple[CrawlOutcomeStatus, list[str]]]):
+ self._graph = graph
+ self.calls: list[str] = []
+
+ async def crawl_url(self, url: str) -> CrawlOutcome:
+ self.calls.append(url)
+ status, links = self._graph[url]
+ if status is _SUCCESS:
+ return CrawlOutcome(
+ status=_SUCCESS,
+ result={
+ "content": f"C:{url}",
+ "metadata": {"title": url},
+ "links": links,
+ },
+ )
+ return CrawlOutcome(status=status, error="boom")
+
+
+async def test_single_url_depth_zero_returns_one_item() -> None:
+ engine = _FakeEngine({"https://e.com/": (_SUCCESS, ["https://e.com/a"])})
+ execute = build_crawl_executor(engine=engine)
+
+ out = await execute(CrawlInput(startUrls=["https://e.com/"]))
+
+ assert isinstance(out, CrawlOutput)
+ assert len(out.items) == 1
+ item = out.items[0]
+ assert item.url == "https://e.com/"
+ assert item.status == "success"
+ assert item.markdown == "C:https://e.com/"
+ assert item.metadata == {"title": "https://e.com/"}
+ assert item.crawl is not None
+ assert item.crawl.depth == 0
+ assert item.crawl.referrerUrl is None
+
+
+async def test_spider_collects_multiple_pages_with_provenance() -> None:
+ engine = _FakeEngine(
+ {
+ "https://e.com/": (_SUCCESS, ["https://e.com/a"]),
+ "https://e.com/a": (_SUCCESS, []),
+ }
+ )
+ execute = build_crawl_executor(engine=engine)
+
+ out = await execute(
+ CrawlInput(startUrls=["https://e.com/"], maxCrawlDepth=1, maxCrawlPages=10)
+ )
+
+ by_url = {item.url: item for item in out.items}
+ assert set(by_url) == {"https://e.com/", "https://e.com/a"}
+ assert by_url["https://e.com/a"].crawl.referrerUrl == "https://e.com/"
+
+
+async def test_content_is_truncated_to_max_length() -> None:
+ engine = _FakeEngine({"https://e.com/": (_SUCCESS, [])})
+ execute = build_crawl_executor(engine=engine)
+
+ out = await execute(CrawlInput(startUrls=["https://e.com/"], maxLength=3))
+
+ assert out.items[0].markdown == "C:h"
+
+
+async def test_failed_page_has_no_markdown_but_keeps_error() -> None:
+ engine = _FakeEngine({"https://e.com/": (CrawlOutcomeStatus.FAILED, [])})
+ execute = build_crawl_executor(engine=engine)
+
+ out = await execute(CrawlInput(startUrls=["https://e.com/"]))
+
+ item = out.items[0]
+ assert item.status == "failed"
+ assert item.markdown is None
+ assert item.error == "boom"
+
+
+async def test_aggregated_contacts_carry_provenance_and_site_wide_flag() -> None:
+ footer = "https://linkedin.com/company/e"
+ person = "https://linkedin.com/in/jane"
+
+ class _ContactsEngine:
+ async def crawl_url(self, url: str) -> CrawlOutcome:
+ socials = [footer] + ([person] if url.endswith("/about") else [])
+ links = (
+ ["https://e.com/about", "https://e.com/blog"]
+ if url == "https://e.com/"
+ else []
+ )
+ return CrawlOutcome(
+ status=_SUCCESS,
+ result={
+ "content": "ok",
+ "metadata": {},
+ "links": links,
+ "contacts": {"emails": [], "phones": [], "socials": socials},
+ },
+ )
+
+ execute = build_crawl_executor(engine=_ContactsEngine())
+ out = await execute(
+ CrawlInput(startUrls=["https://e.com/"], maxCrawlDepth=1, maxCrawlPages=10)
+ )
+
+ by_value = {ref.value: ref for ref in out.contacts.socials}
+ assert by_value[footer].siteWide # on all 3 pages -> boilerplate
+ assert by_value[footer].pageCount == 3
+ assert not by_value[person].siteWide # only on /about -> page-local entity
+ assert by_value[person].pages == ["https://e.com/about"]
+
+
+async def test_single_page_crawl_marks_contacts_site_wide() -> None:
+ class _OnePageEngine:
+ async def crawl_url(self, url: str) -> CrawlOutcome:
+ return CrawlOutcome(
+ status=_SUCCESS,
+ result={
+ "content": "ok",
+ "metadata": {},
+ "links": [],
+ "contacts": {"emails": ["a@e.com"], "phones": [], "socials": []},
+ },
+ )
+
+ execute = build_crawl_executor(engine=_OnePageEngine())
+ out = await execute(CrawlInput(startUrls=["https://e.com/"]))
+
+ assert out.contacts.emails[0].siteWide # one page: no signal to split on
+
+
+async def test_captcha_telemetry_is_rolled_up_for_billing() -> None:
+ class _CaptchaEngine:
+ async def crawl_url(self, url: str) -> CrawlOutcome:
+ return CrawlOutcome(
+ status=_SUCCESS,
+ result={"content": "ok", "metadata": {}, "links": []},
+ captcha_attempts=2,
+ captcha_solved=True,
+ )
+
+ execute = build_crawl_executor(engine=_CaptchaEngine())
+
+ out = await execute(CrawlInput(startUrls=["https://e.com/"]))
+
+ assert out.captcha_attempts == 2
+ assert out.captcha_solved == 1
+ assert out.billable_units == 1
diff --git a/surfsense_backend/tests/unit/capabilities/web/crawl/test_schemas.py b/surfsense_backend/tests/unit/capabilities/web/crawl/test_schemas.py
new file mode 100644
index 000000000..f7b9a2b39
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/web/crawl/test_schemas.py
@@ -0,0 +1,69 @@
+"""``web.crawl`` I/O contract: camelCase surface, bounds, and billing counters."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from app.capabilities.web.crawl.schemas import (
+ CrawlInput,
+ CrawlItem,
+ CrawlMeta,
+ CrawlOutput,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_requires_at_least_one_start_url() -> None:
+ with pytest.raises(ValidationError):
+ CrawlInput(startUrls=[])
+
+
+def test_camelcase_fields_and_defaults() -> None:
+ model = CrawlInput(startUrls=["https://e.com"])
+ assert model.startUrls == ["https://e.com"]
+ assert model.maxCrawlDepth == 0
+ assert model.maxCrawlPages == 10
+ assert model.maxLength == 50_000
+
+
+def test_depth_and_page_bounds_are_enforced() -> None:
+ with pytest.raises(ValidationError):
+ CrawlInput(startUrls=["https://e.com"], maxCrawlDepth=-1)
+ with pytest.raises(ValidationError):
+ CrawlInput(startUrls=["https://e.com"], maxCrawlDepth=99)
+ with pytest.raises(ValidationError):
+ CrawlInput(startUrls=["https://e.com"], maxCrawlPages=0)
+
+
+def test_estimated_units_for_single_url_is_seed_count() -> None:
+ model = CrawlInput(startUrls=["https://a.com", "https://b.com"], maxCrawlDepth=0)
+ assert model.estimated_units == 2
+
+
+def test_estimated_units_for_spider_is_max_pages() -> None:
+ model = CrawlInput(startUrls=["https://a.com"], maxCrawlDepth=2, maxCrawlPages=25)
+ assert model.estimated_units == 25
+
+
+def test_billable_units_counts_only_successes() -> None:
+ out = CrawlOutput(
+ items=[
+ CrawlItem(
+ url="a", status="success", crawl=CrawlMeta(loadedUrl="a", depth=0)
+ ),
+ CrawlItem(url="b", status="empty", crawl=CrawlMeta(loadedUrl="b", depth=1)),
+ CrawlItem(
+ url="c", status="failed", crawl=CrawlMeta(loadedUrl="c", depth=1)
+ ),
+ ]
+ )
+ assert out.billable_units == 1
+
+
+def test_captcha_counters_are_excluded_from_the_wire_shape() -> None:
+ out = CrawlOutput(items=[], captcha_attempts=3, captcha_solved=1)
+ dumped = out.model_dump()
+ assert "captcha_attempts" not in dumped
+ assert "captcha_solved" not in dumped
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/__init__.py b/surfsense_backend/tests/unit/capabilities/youtube/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/comments/__init__.py b/surfsense_backend/tests/unit/capabilities/youtube/comments/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/comments/test_executor.py b/surfsense_backend/tests/unit/capabilities/youtube/comments/test_executor.py
new file mode 100644
index 000000000..fd153fed5
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/youtube/comments/test_executor.py
@@ -0,0 +1,55 @@
+"""``youtube.comments`` executor: verb input → actor input mapping → typed items."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities.youtube.comments.executor import build_comments_executor
+from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOutput
+from app.proprietary.platforms.youtube import YouTubeCommentsInput
+
+pytestmark = pytest.mark.unit
+
+
+class _FakeScraper:
+ def __init__(self, items: list[dict]):
+ self._items = items
+ self.calls: list[YouTubeCommentsInput] = []
+
+ async def __call__(self, actor_input: YouTubeCommentsInput) -> list[dict]:
+ self.calls.append(actor_input)
+ return self._items
+
+
+async def test_maps_urls_and_wraps_comment_items():
+ scraper = _FakeScraper([{"cid": "c1", "comment": "nice", "author": "@a"}])
+ execute = build_comments_executor(scrape_fn=scraper)
+
+ out = await execute(CommentsInput(urls=["https://www.youtube.com/watch?v=abc"]))
+
+ assert isinstance(out, CommentsOutput)
+ assert len(out.items) == 1
+ assert out.items[0].cid == "c1"
+ assert out.items[0].comment == "nice"
+
+ (actor_input,) = scraper.calls
+ assert [u.url for u in actor_input.startUrls] == [
+ "https://www.youtube.com/watch?v=abc"
+ ]
+
+
+async def test_forwards_max_comments_and_sort():
+ scraper = _FakeScraper([])
+ execute = build_comments_executor(scrape_fn=scraper)
+
+ await execute(
+ CommentsInput(
+ urls=["https://youtu.be/abc"],
+ max_comments=50,
+ sort_by="TOP_COMMENTS",
+ )
+ )
+
+ (actor_input,) = scraper.calls
+ assert actor_input.maxComments == 50
+ assert actor_input.sortCommentsBy == "TOP_COMMENTS"
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/comments/test_schemas.py b/surfsense_backend/tests/unit/capabilities/youtube/comments/test_schemas.py
new file mode 100644
index 000000000..129473f7b
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/youtube/comments/test_schemas.py
@@ -0,0 +1,35 @@
+"""``youtube.comments`` input guards: URLs required and batch/count bounded."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from app.capabilities.youtube.comments.schemas import (
+ MAX_COMMENT_VIDEOS,
+ CommentsInput,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_rejects_empty_url_batch():
+ with pytest.raises(ValidationError):
+ CommentsInput(urls=[])
+
+
+def test_rejects_batch_over_the_cap():
+ too_many = [f"https://youtu.be/{i}" for i in range(MAX_COMMENT_VIDEOS + 1)]
+ with pytest.raises(ValidationError):
+ CommentsInput(urls=too_many)
+
+
+def test_defaults_max_comments_and_newest_first():
+ payload = CommentsInput(urls=["https://youtu.be/abc"])
+ assert payload.max_comments == 20
+ assert payload.sort_by == "NEWEST_FIRST"
+
+
+def test_rejects_zero_max_comments():
+ with pytest.raises(ValidationError):
+ CommentsInput(urls=["https://youtu.be/abc"], max_comments=0)
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/scrape/__init__.py b/surfsense_backend/tests/unit/capabilities/youtube/scrape/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/scrape/test_executor.py b/surfsense_backend/tests/unit/capabilities/youtube/scrape/test_executor.py
new file mode 100644
index 000000000..48c849cb5
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/youtube/scrape/test_executor.py
@@ -0,0 +1,87 @@
+"""``youtube.scrape`` executor: verb input → actor input mapping → typed items.
+
+Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
+own payload→YouTubeScrapeInput mapping and the dict→VideoItem wrapping.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities.youtube.scrape.executor import build_scrape_executor
+from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput
+from app.proprietary.platforms.youtube import YouTubeScrapeInput
+
+pytestmark = pytest.mark.unit
+
+
+class _FakeScraper:
+ """Records the actor input it was called with and returns canned items."""
+
+ def __init__(self, items: list[dict]):
+ self._items = items
+ self.calls: list[YouTubeScrapeInput] = []
+
+ async def __call__(self, actor_input: YouTubeScrapeInput) -> list[dict]:
+ self.calls.append(actor_input)
+ return self._items
+
+
+async def test_maps_urls_to_start_urls_and_wraps_items():
+ scraper = _FakeScraper([{"id": "abc", "title": "Hello"}])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ out = await execute(ScrapeInput(urls=["https://www.youtube.com/watch?v=abc"]))
+
+ assert isinstance(out, ScrapeOutput)
+ assert len(out.items) == 1
+ assert out.items[0].id == "abc"
+ assert out.items[0].title == "Hello"
+
+ (actor_input,) = scraper.calls
+ assert [u.url for u in actor_input.startUrls] == [
+ "https://www.youtube.com/watch?v=abc"
+ ]
+ assert actor_input.searchQueries == []
+
+
+async def test_forwards_search_queries():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(ScrapeInput(search_queries=["python", "rust"]))
+
+ (actor_input,) = scraper.calls
+ assert actor_input.searchQueries == ["python", "rust"]
+ assert actor_input.startUrls == []
+
+
+async def test_max_results_caps_every_content_type():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(ScrapeInput(search_queries=["x"], max_results=7))
+
+ (actor_input,) = scraper.calls
+ # Videos, shorts, and streams must all inherit the caller's cap, otherwise a
+ # channel scrape would silently return only plain videos (actor default 0).
+ assert actor_input.maxResults == 7
+ assert actor_input.maxResultsShorts == 7
+ assert actor_input.maxResultStreams == 7
+
+
+async def test_forwards_subtitle_options():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(
+ ScrapeInput(
+ urls=["https://youtu.be/abc"],
+ download_subtitles=True,
+ subtitles_language="fr",
+ )
+ )
+
+ (actor_input,) = scraper.calls
+ assert actor_input.downloadSubtitles is True
+ assert actor_input.subtitlesLanguage == "fr"
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/scrape/test_schemas.py b/surfsense_backend/tests/unit/capabilities/youtube/scrape/test_schemas.py
new file mode 100644
index 000000000..9d0477dfe
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/youtube/scrape/test_schemas.py
@@ -0,0 +1,40 @@
+"""``youtube.scrape`` input guards: a source is required and the batch is bounded."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from app.capabilities.youtube.scrape.schemas import (
+ MAX_YOUTUBE_SOURCES,
+ ScrapeInput,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_rejects_input_with_neither_urls_nor_queries():
+ with pytest.raises(ValidationError):
+ ScrapeInput()
+
+
+def test_accepts_urls_only():
+ payload = ScrapeInput(urls=["https://www.youtube.com/watch?v=abc"])
+ assert payload.search_queries == []
+
+
+def test_accepts_search_queries_only():
+ payload = ScrapeInput(search_queries=["python tutorial"])
+ assert payload.urls == []
+
+
+def test_max_results_defaults_and_is_bounded():
+ assert ScrapeInput(search_queries=["x"]).max_results == 10
+ with pytest.raises(ValidationError):
+ ScrapeInput(search_queries=["x"], max_results=0)
+
+
+def test_rejects_more_sources_than_the_cap():
+ too_many = [f"https://youtu.be/{i}" for i in range(MAX_YOUTUBE_SOURCES + 1)]
+ with pytest.raises(ValidationError):
+ ScrapeInput(urls=too_many)
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/test_registry.py b/surfsense_backend/tests/unit/capabilities/youtube/test_registry.py
new file mode 100644
index 000000000..756b65176
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/youtube/test_registry.py
@@ -0,0 +1,32 @@
+"""The youtube namespace registers each verb as one Capability the doors/agent read."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities import (
+ youtube, # noqa: F401 — importing the namespace registers its verbs
+)
+from app.capabilities.core.store import get_capability
+from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOutput
+from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput
+
+pytestmark = pytest.mark.unit
+
+
+def test_youtube_scrape_is_registered_and_free():
+ cap = get_capability("youtube.scrape")
+
+ assert cap.name == "youtube.scrape"
+ assert cap.input_schema is ScrapeInput
+ assert cap.output_schema is ScrapeOutput
+ assert cap.billing_unit is None
+
+
+def test_youtube_comments_is_registered_and_free():
+ cap = get_capability("youtube.comments")
+
+ assert cap.name == "youtube.comments"
+ assert cap.input_schema is CommentsInput
+ assert cap.output_schema is CommentsOutput
+ assert cap.billing_unit is None
diff --git a/surfsense_backend/tests/unit/connector_indexers/test_confluence_parallel.py b/surfsense_backend/tests/unit/connector_indexers/test_confluence_parallel.py
index ff85096d4..99af07b72 100644
--- a/surfsense_backend/tests/unit/connector_indexers/test_confluence_parallel.py
+++ b/surfsense_backend/tests/unit/connector_indexers/test_confluence_parallel.py
@@ -69,7 +69,7 @@ async def test_build_connector_doc_produces_correct_fields():
page,
markdown,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -77,7 +77,7 @@ async def test_build_connector_doc_produces_correct_fields():
assert doc.unique_id == "abc-123"
assert doc.document_type == DocumentType.CONFLUENCE_CONNECTOR
assert doc.source_markdown == markdown
- assert doc.search_space_id == _SEARCH_SPACE_ID
+ assert doc.workspace_id == _SEARCH_SPACE_ID
assert doc.connector_id == _CONNECTOR_ID
assert doc.created_by_id == _USER_ID
assert doc.metadata["page_id"] == "abc-123"
@@ -181,7 +181,7 @@ async def _run_index(mocks, **overrides):
return await index_confluence_pages(
session=mocks["session"],
connector_id=overrides.get("connector_id", _CONNECTOR_ID),
- search_space_id=overrides.get("search_space_id", _SEARCH_SPACE_ID),
+ workspace_id=overrides.get("workspace_id", _SEARCH_SPACE_ID),
user_id=overrides.get("user_id", _USER_ID),
start_date=overrides.get("start_date", "2025-01-01"),
end_date=overrides.get("end_date", "2025-12-31"),
diff --git a/surfsense_backend/tests/unit/connector_indexers/test_dropbox_parallel.py b/surfsense_backend/tests/unit/connector_indexers/test_dropbox_parallel.py
index a74591169..034aeb8b8 100644
--- a/surfsense_backend/tests/unit/connector_indexers/test_dropbox_parallel.py
+++ b/surfsense_backend/tests/unit/connector_indexers/test_dropbox_parallel.py
@@ -69,7 +69,7 @@ async def test_single_file_returns_one_connector_document(
mock_dropbox_client,
[_make_file_dict("f1", "test.txt")],
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -94,7 +94,7 @@ async def test_multiple_files_all_produce_documents(
mock_dropbox_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -121,7 +121,7 @@ async def test_one_download_exception_does_not_block_others(
mock_dropbox_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -147,7 +147,7 @@ async def test_etl_error_counts_as_download_failure(
mock_dropbox_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -185,7 +185,7 @@ async def test_concurrency_bounded_by_semaphore(
mock_dropbox_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
max_concurrency=2,
)
@@ -224,7 +224,7 @@ async def test_heartbeat_fires_during_parallel_downloads(
mock_dropbox_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
on_heartbeat=_on_heartbeat,
)
@@ -257,7 +257,7 @@ def full_scan_mocks(mock_dropbox_client, monkeypatch):
monkeypatch.setattr("app.config.config.ETL_SERVICE", "LLAMACLOUD")
- async def _fake_skip(session, file, search_space_id):
+ async def _fake_skip(session, file, workspace_id):
from app.connectors.dropbox.file_types import should_skip_file as _skip
item_skip, unsup_ext = _skip(file)
@@ -389,7 +389,7 @@ def selected_files_mocks(mock_dropbox_client, monkeypatch):
skip_results: dict[str, tuple[bool, str | None]] = {}
- async def _fake_skip(session, file, search_space_id):
+ async def _fake_skip(session, file, workspace_id):
return skip_results.get(file["id"], (False, None))
monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip)
@@ -430,7 +430,7 @@ async def _run_selected(mocks, file_tuples):
mocks["session"],
file_tuples,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -547,7 +547,7 @@ async def test_delta_sync_deletions_call_remove_document(monkeypatch):
remove_calls: list[str] = []
- async def _fake_remove(session, file_id, search_space_id):
+ async def _fake_remove(session, file_id, workspace_id):
remove_calls.append(file_id)
monkeypatch.setattr(_mod, "_remove_document", _fake_remove)
@@ -641,7 +641,7 @@ async def test_delta_sync_mix_deletions_and_upserts(monkeypatch):
remove_calls: list[str] = []
- async def _fake_remove(session, file_id, search_space_id):
+ async def _fake_remove(session, file_id, workspace_id):
remove_calls.append(file_id)
monkeypatch.setattr(_mod, "_remove_document", _fake_remove)
diff --git a/surfsense_backend/tests/unit/connector_indexers/test_etl_credits.py b/surfsense_backend/tests/unit/connector_indexers/test_etl_credits.py
index aca811ee9..a2f78751c 100644
--- a/surfsense_backend/tests/unit/connector_indexers/test_etl_credits.py
+++ b/surfsense_backend/tests/unit/connector_indexers/test_etl_credits.py
@@ -201,7 +201,7 @@ async def _run_gdrive_selected(mocks, file_ids):
mocks["session"],
file_ids,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -542,7 +542,7 @@ async def _run_onedrive_selected(mocks, file_ids):
mocks["session"],
file_ids,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -641,7 +641,7 @@ async def _run_dropbox_selected(mocks, file_paths):
mocks["session"],
file_paths,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
diff --git a/surfsense_backend/tests/unit/connector_indexers/test_google_drive_parallel.py b/surfsense_backend/tests/unit/connector_indexers/test_google_drive_parallel.py
index 4f61976a6..53ccb690d 100644
--- a/surfsense_backend/tests/unit/connector_indexers/test_google_drive_parallel.py
+++ b/surfsense_backend/tests/unit/connector_indexers/test_google_drive_parallel.py
@@ -64,7 +64,7 @@ async def test_single_file_returns_one_connector_document(
mock_drive_client,
[_make_file_dict("f1", "test.txt")],
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -88,7 +88,7 @@ async def test_multiple_files_all_produce_documents(
mock_drive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -115,7 +115,7 @@ async def test_one_download_exception_does_not_block_others(
mock_drive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -141,7 +141,7 @@ async def test_etl_error_counts_as_download_failure(
mock_drive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -180,7 +180,7 @@ async def test_concurrency_bounded_by_semaphore(
mock_drive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
max_concurrency=2,
)
@@ -219,7 +219,7 @@ async def test_heartbeat_fires_during_parallel_downloads(
mock_drive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
on_heartbeat=_on_heartbeat,
)
@@ -284,7 +284,7 @@ def full_scan_mocks(mock_drive_client, monkeypatch):
skip_results: dict[str, tuple[bool, str | None]] = {}
- async def _fake_skip(session, file, search_space_id):
+ async def _fake_skip(session, file, workspace_id):
return skip_results.get(file["id"], (False, None))
monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip)
@@ -458,7 +458,7 @@ async def test_delta_sync_removals_serial_rest_parallel(monkeypatch):
remove_calls: list[str] = []
- async def _fake_remove(session, file_id, search_space_id):
+ async def _fake_remove(session, file_id, workspace_id):
remove_calls.append(file_id)
monkeypatch.setattr(_mod, "_remove_document", _fake_remove)
@@ -532,7 +532,7 @@ def selected_files_mocks(mock_drive_client, monkeypatch):
skip_results: dict[str, tuple[bool, str | None]] = {}
- async def _fake_skip(session, file, search_space_id):
+ async def _fake_skip(session, file, workspace_id):
return skip_results.get(file["id"], (False, None))
monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip)
@@ -563,7 +563,7 @@ async def _run_selected(mocks, file_ids):
mocks["session"],
file_ids,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
diff --git a/surfsense_backend/tests/unit/connector_indexers/test_linear_parallel.py b/surfsense_backend/tests/unit/connector_indexers/test_linear_parallel.py
index f057a6352..3639dcebb 100644
--- a/surfsense_backend/tests/unit/connector_indexers/test_linear_parallel.py
+++ b/surfsense_backend/tests/unit/connector_indexers/test_linear_parallel.py
@@ -68,7 +68,7 @@ async def test_build_connector_doc_produces_correct_fields():
formatted,
markdown,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -76,7 +76,7 @@ async def test_build_connector_doc_produces_correct_fields():
assert doc.unique_id == "abc-123"
assert doc.document_type == DocumentType.LINEAR_CONNECTOR
assert doc.source_markdown == markdown
- assert doc.search_space_id == _SEARCH_SPACE_ID
+ assert doc.workspace_id == _SEARCH_SPACE_ID
assert doc.connector_id == _CONNECTOR_ID
assert doc.created_by_id == _USER_ID
assert doc.metadata["issue_id"] == "abc-123"
@@ -196,7 +196,7 @@ async def _run_index(mocks, **overrides):
return await index_linear_issues(
session=mocks["session"],
connector_id=overrides.get("connector_id", _CONNECTOR_ID),
- search_space_id=overrides.get("search_space_id", _SEARCH_SPACE_ID),
+ workspace_id=overrides.get("workspace_id", _SEARCH_SPACE_ID),
user_id=overrides.get("user_id", _USER_ID),
start_date=overrides.get("start_date", "2025-01-01"),
end_date=overrides.get("end_date", "2025-12-31"),
diff --git a/surfsense_backend/tests/unit/connector_indexers/test_notion_parallel.py b/surfsense_backend/tests/unit/connector_indexers/test_notion_parallel.py
index e40f739d8..35c3f3f69 100644
--- a/surfsense_backend/tests/unit/connector_indexers/test_notion_parallel.py
+++ b/surfsense_backend/tests/unit/connector_indexers/test_notion_parallel.py
@@ -39,7 +39,7 @@ async def test_build_connector_doc_produces_correct_fields():
page,
markdown,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -47,7 +47,7 @@ async def test_build_connector_doc_produces_correct_fields():
assert doc.unique_id == "abc-123"
assert doc.document_type == DocumentType.NOTION_CONNECTOR
assert doc.source_markdown == markdown
- assert doc.search_space_id == _SEARCH_SPACE_ID
+ assert doc.workspace_id == _SEARCH_SPACE_ID
assert doc.connector_id == _CONNECTOR_ID
assert doc.created_by_id == _USER_ID
assert doc.metadata["page_title"] == "My Notion Page"
@@ -159,7 +159,7 @@ async def _run_index(mocks, **overrides):
return await index_notion_pages(
session=mocks["session"],
connector_id=overrides.get("connector_id", _CONNECTOR_ID),
- search_space_id=overrides.get("search_space_id", _SEARCH_SPACE_ID),
+ workspace_id=overrides.get("workspace_id", _SEARCH_SPACE_ID),
user_id=overrides.get("user_id", _USER_ID),
start_date=overrides.get("start_date", "2025-01-01"),
end_date=overrides.get("end_date", "2025-12-31"),
diff --git a/surfsense_backend/tests/unit/connector_indexers/test_onedrive_parallel.py b/surfsense_backend/tests/unit/connector_indexers/test_onedrive_parallel.py
index 01e81da17..3fca64bbc 100644
--- a/surfsense_backend/tests/unit/connector_indexers/test_onedrive_parallel.py
+++ b/surfsense_backend/tests/unit/connector_indexers/test_onedrive_parallel.py
@@ -63,7 +63,7 @@ async def test_single_file_returns_one_connector_document(
mock_onedrive_client,
[_make_file_dict("f1", "test.txt")],
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -88,7 +88,7 @@ async def test_multiple_files_all_produce_documents(
mock_onedrive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -115,7 +115,7 @@ async def test_one_download_exception_does_not_block_others(
mock_onedrive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -141,7 +141,7 @@ async def test_etl_error_counts_as_download_failure(
mock_onedrive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
)
@@ -179,7 +179,7 @@ async def test_concurrency_bounded_by_semaphore(
mock_onedrive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
max_concurrency=2,
)
@@ -218,7 +218,7 @@ async def test_heartbeat_fires_during_parallel_downloads(
mock_onedrive_client,
files,
connector_id=_CONNECTOR_ID,
- search_space_id=_SEARCH_SPACE_ID,
+ workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID,
on_heartbeat=_on_heartbeat,
)
diff --git a/surfsense_backend/tests/unit/event_bus/test_bus.py b/surfsense_backend/tests/unit/event_bus/test_bus.py
index 6c970760f..c038092a5 100644
--- a/surfsense_backend/tests/unit/event_bus/test_bus.py
+++ b/surfsense_backend/tests/unit/event_bus/test_bus.py
@@ -13,7 +13,7 @@ pytestmark = pytest.mark.unit
def _event() -> Event:
- return Event(event_type="x.happened", payload={"k": "v"}, search_space_id=1)
+ return Event(event_type="x.happened", payload={"k": "v"}, workspace_id=1)
async def _noop(_event: Event) -> None:
@@ -152,13 +152,13 @@ async def test_publish_builds_a_stamped_event_and_fans_it_out() -> None:
received.append(event)
bus.subscribe(handler)
- await bus.publish("document.indexed", {"document_id": 42}, search_space_id=7)
+ await bus.publish("document.indexed", {"document_id": 42}, workspace_id=7)
assert len(received) == 1
event = received[0]
assert event.event_type == "document.indexed"
assert event.payload == {"document_id": 42}
- assert event.search_space_id == 7
+ assert event.workspace_id == 7
# Engine-stamped identity/time on the way through.
assert event.event_id
assert event.occurred_at
@@ -172,10 +172,10 @@ async def test_publish_defaults_payload_to_empty_dict() -> None:
received.append(event)
bus.subscribe(handler)
- await bus.publish("x.happened", search_space_id=1)
+ await bus.publish("x.happened", workspace_id=1)
assert received[0].payload == {}
async def test_publish_with_no_subscribers_is_a_noop() -> None:
- await EventBus().publish("x.happened", search_space_id=1) # must not raise
+ await EventBus().publish("x.happened", workspace_id=1) # must not raise
diff --git a/surfsense_backend/tests/unit/event_bus/test_entered_folder_predicate.py b/surfsense_backend/tests/unit/event_bus/test_entered_folder_predicate.py
index 1f71e3abb..32f908083 100644
--- a/surfsense_backend/tests/unit/event_bus/test_entered_folder_predicate.py
+++ b/surfsense_backend/tests/unit/event_bus/test_entered_folder_predicate.py
@@ -14,7 +14,7 @@ pytestmark = pytest.mark.unit
def _call(**overrides: Any) -> dict[str, Any] | None:
defaults: dict[str, Any] = {
"document_id": 1,
- "search_space_id": 10,
+ "workspace_id": 10,
"new_folder_id": 7,
"previous_folder_id": None,
"folder_id_changed": True,
@@ -33,7 +33,7 @@ def test_folder_set_ready_fires() -> None:
assert result is not None
assert result["event_type"] == "document.entered_folder"
- assert result["search_space_id"] == 10
+ assert result["workspace_id"] == 10
assert result["payload"]["folder_id"] == 7
assert result["payload"]["previous_folder_id"] is None
diff --git a/surfsense_backend/tests/unit/event_bus/test_event.py b/surfsense_backend/tests/unit/event_bus/test_event.py
index d09cb4364..23a6a2393 100644
--- a/surfsense_backend/tests/unit/event_bus/test_event.py
+++ b/surfsense_backend/tests/unit/event_bus/test_event.py
@@ -16,17 +16,17 @@ def test_event_carries_caller_supplied_facts() -> None:
event = Event(
event_type="document.indexed",
payload={"document_id": 42, "content_type": "pdf"},
- search_space_id=7,
+ workspace_id=7,
)
assert event.event_type == "document.indexed"
assert event.payload == {"document_id": 42, "content_type": "pdf"}
- assert event.search_space_id == 7
+ assert event.workspace_id == 7
def test_event_stamps_identity_and_time_when_not_supplied() -> None:
"""Engine stamps id + time so subscribers can dedup/order."""
- event = Event(event_type="x.happened", payload={}, search_space_id=1)
+ event = Event(event_type="x.happened", payload={}, workspace_id=1)
assert event.event_id
assert isinstance(event.occurred_at, datetime)
@@ -34,8 +34,8 @@ def test_event_stamps_identity_and_time_when_not_supplied() -> None:
def test_event_ids_are_unique_per_instance() -> None:
"""Two events published with identical content are still distinct facts."""
- first = Event(event_type="x.happened", payload={}, search_space_id=1)
- second = Event(event_type="x.happened", payload={}, search_space_id=1)
+ first = Event(event_type="x.happened", payload={}, workspace_id=1)
+ second = Event(event_type="x.happened", payload={}, workspace_id=1)
assert first.event_id != second.event_id
@@ -45,7 +45,7 @@ def test_event_survives_json_round_trip() -> None:
original = Event(
event_type="podcast.generated",
payload={"podcast_id": 9, "duration_s": 123.5},
- search_space_id=3,
+ workspace_id=3,
)
restored = Event.model_validate_json(original.model_dump_json())
diff --git a/surfsense_backend/tests/unit/gateway/test_webhook_routes.py b/surfsense_backend/tests/unit/gateway/test_webhook_routes.py
index 354c3037d..967d3ba58 100644
--- a/surfsense_backend/tests/unit/gateway/test_webhook_routes.py
+++ b/surfsense_backend/tests/unit/gateway/test_webhook_routes.py
@@ -330,10 +330,10 @@ async def test_discord_gateway_install_returns_oauth_url(monkeypatch, mocker):
"http://localhost:8000/api/v1/gateway/discord/callback",
)
monkeypatch.setattr(routes.config, "SECRET_KEY", "test-secret")
- monkeypatch.setattr(routes, "check_search_space_access", mocker.AsyncMock())
+ monkeypatch.setattr(routes, "check_workspace_access", mocker.AsyncMock())
response = await routes.install_discord_gateway(
- search_space_id=123,
+ workspace_id=123,
auth=AuthContext.session(
SimpleNamespace(id="00000000-0000-0000-0000-000000000001")
),
diff --git a/surfsense_backend/tests/unit/indexing_pipeline/test_connector_document.py b/surfsense_backend/tests/unit/indexing_pipeline/test_connector_document.py
index f85c632ef..20607508e 100644
--- a/surfsense_backend/tests/unit/indexing_pipeline/test_connector_document.py
+++ b/surfsense_backend/tests/unit/indexing_pipeline/test_connector_document.py
@@ -14,7 +14,7 @@ def test_valid_document_created_with_required_fields():
source_markdown="## Task\n\nSome content.",
unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR,
- search_space_id=1,
+ workspace_id=1,
connector_id=42,
created_by_id="00000000-0000-0000-0000-000000000001",
)
@@ -32,7 +32,7 @@ def test_omitting_created_by_id_raises():
source_markdown="## Content",
unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR,
- search_space_id=1,
+ workspace_id=1,
connector_id=42,
)
@@ -45,7 +45,7 @@ def test_empty_source_markdown_raises():
source_markdown="",
unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR,
- search_space_id=1,
+ workspace_id=1,
)
@@ -57,7 +57,7 @@ def test_whitespace_only_source_markdown_raises():
source_markdown=" \n\t ",
unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR,
- search_space_id=1,
+ workspace_id=1,
)
@@ -69,7 +69,7 @@ def test_empty_title_raises():
source_markdown="## Content",
unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR,
- search_space_id=1,
+ workspace_id=1,
)
@@ -81,21 +81,21 @@ def test_empty_created_by_id_raises():
source_markdown="## Content",
unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR,
- search_space_id=1,
+ workspace_id=1,
connector_id=42,
created_by_id="",
)
-def test_zero_search_space_id_raises():
- """search_space_id of zero raises a validation error."""
+def test_zero_workspace_id_raises():
+ """workspace_id of zero raises a validation error."""
with pytest.raises(ValidationError):
ConnectorDocument(
title="Task",
source_markdown="## Content",
unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR,
- search_space_id=0,
+ workspace_id=0,
connector_id=42,
created_by_id="00000000-0000-0000-0000-000000000001",
)
@@ -109,5 +109,5 @@ def test_empty_unique_id_raises():
source_markdown="## Content",
unique_id="",
document_type=DocumentType.CLICKUP_CONNECTOR,
- search_space_id=1,
+ workspace_id=1,
)
diff --git a/surfsense_backend/tests/unit/indexing_pipeline/test_create_placeholder_documents.py b/surfsense_backend/tests/unit/indexing_pipeline/test_create_placeholder_documents.py
index 096134efd..374f5156f 100644
--- a/surfsense_backend/tests/unit/indexing_pipeline/test_create_placeholder_documents.py
+++ b/surfsense_backend/tests/unit/indexing_pipeline/test_create_placeholder_documents.py
@@ -27,7 +27,7 @@ def _make_placeholder(**overrides) -> PlaceholderInfo:
"title": "Test Doc",
"document_type": DocumentType.GOOGLE_DRIVE_FILE,
"unique_id": "file-001",
- "search_space_id": 1,
+ "workspace_id": 1,
"connector_id": 42,
"created_by_id": "00000000-0000-0000-0000-000000000001",
}
@@ -36,9 +36,7 @@ def _make_placeholder(**overrides) -> PlaceholderInfo:
def _uid_hash(p: PlaceholderInfo) -> str:
- return compute_identifier_hash(
- p.document_type.value, p.unique_id, p.search_space_id
- )
+ return compute_identifier_hash(p.document_type.value, p.unique_id, p.workspace_id)
def _session_with_existing_hashes(existing: set[str] | None = None):
@@ -82,7 +80,7 @@ async def test_creates_documents_with_pending_status_and_commits():
assert doc.document_type == DocumentType.GOOGLE_DRIVE_FILE
assert doc.content == "Pending..."
assert DocumentStatus.is_state(doc.status, DocumentStatus.PENDING)
- assert doc.search_space_id == 1
+ assert doc.workspace_id == 1
assert doc.connector_id == 42
session.commit.assert_awaited_once()
diff --git a/surfsense_backend/tests/unit/indexing_pipeline/test_document_hashing.py b/surfsense_backend/tests/unit/indexing_pipeline/test_document_hashing.py
index d04d8b048..6a7c0ddf0 100644
--- a/surfsense_backend/tests/unit/indexing_pipeline/test_document_hashing.py
+++ b/surfsense_backend/tests/unit/indexing_pipeline/test_document_hashing.py
@@ -19,12 +19,12 @@ def test_different_unique_id_produces_different_hash(make_connector_document):
)
-def test_different_search_space_produces_different_identifier_hash(
+def test_different_workspace_produces_different_identifier_hash(
make_connector_document,
):
- """Same document in different search spaces produces different identifier hashes."""
- doc_a = make_connector_document(search_space_id=1)
- doc_b = make_connector_document(search_space_id=2)
+ """Same document in different workspaces produces different identifier hashes."""
+ doc_a = make_connector_document(workspace_id=1)
+ doc_b = make_connector_document(workspace_id=2)
assert compute_unique_identifier_hash(doc_a) != compute_unique_identifier_hash(
doc_b
)
@@ -42,18 +42,18 @@ def test_different_document_type_produces_different_identifier_hash(
def test_same_content_same_space_produces_same_content_hash(make_connector_document):
- """Identical content in the same search space always produces the same content hash."""
- doc_a = make_connector_document(source_markdown="Hello world", search_space_id=1)
- doc_b = make_connector_document(source_markdown="Hello world", search_space_id=1)
+ """Identical content in the same workspace always produces the same content hash."""
+ doc_a = make_connector_document(source_markdown="Hello world", workspace_id=1)
+ doc_b = make_connector_document(source_markdown="Hello world", workspace_id=1)
assert compute_content_hash(doc_a) == compute_content_hash(doc_b)
def test_same_content_different_space_produces_different_content_hash(
make_connector_document,
):
- """Identical content in different search spaces produces different content hashes."""
- doc_a = make_connector_document(source_markdown="Hello world", search_space_id=1)
- doc_b = make_connector_document(source_markdown="Hello world", search_space_id=2)
+ """Identical content in different workspaces produces different content hashes."""
+ doc_a = make_connector_document(source_markdown="Hello world", workspace_id=1)
+ doc_b = make_connector_document(source_markdown="Hello world", workspace_id=2)
assert compute_content_hash(doc_a) != compute_content_hash(doc_b)
@@ -69,7 +69,7 @@ def test_compute_identifier_hash_matches_connector_doc_hash(make_connector_docum
doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-123",
- search_space_id=5,
+ workspace_id=5,
)
raw_hash = compute_identifier_hash("GOOGLE_GMAIL_CONNECTOR", "msg-123", 5)
assert raw_hash == compute_unique_identifier_hash(doc)
diff --git a/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch.py b/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch.py
index 963ac6792..c0d18c9c2 100644
--- a/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch.py
+++ b/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch.py
@@ -24,12 +24,12 @@ async def test_calls_prepare_then_index_per_document(pipeline, make_connector_do
doc1 = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1",
- search_space_id=1,
+ workspace_id=1,
)
doc2 = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-2",
- search_space_id=1,
+ workspace_id=1,
)
orm1 = MagicMock(spec=Document)
@@ -63,7 +63,7 @@ async def test_skips_document_without_matching_connector_doc(
doc1 = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1",
- search_space_id=1,
+ workspace_id=1,
)
orphan_orm = MagicMock(spec=Document)
diff --git a/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch_parallel.py b/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch_parallel.py
index feb7bbc52..13fc5fc70 100644
--- a/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch_parallel.py
+++ b/surfsense_backend/tests/unit/indexing_pipeline/test_index_batch_parallel.py
@@ -82,7 +82,7 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1",
- search_space_id=1,
+ workspace_id=1,
)
document = MagicMock(spec=Document)
document.id = 1
@@ -140,7 +140,7 @@ async def test_non_code_documents_use_hybrid_chunker(
connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1",
- search_space_id=1,
+ workspace_id=1,
should_use_code_chunker=False,
)
document = MagicMock(spec=Document)
@@ -184,7 +184,7 @@ async def test_batch_parallel_indexes_all_documents(
make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id=f"msg-{i}",
- search_space_id=1,
+ workspace_id=1,
)
for i in range(3)
]
@@ -222,7 +222,7 @@ async def test_batch_parallel_one_failure_does_not_affect_others(
make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id=f"msg-{i}",
- search_space_id=1,
+ workspace_id=1,
)
for i in range(3)
]
diff --git a/surfsense_backend/tests/unit/indexing_pipeline/test_migrate_legacy_docs.py b/surfsense_backend/tests/unit/indexing_pipeline/test_migrate_legacy_docs.py
index 9334fe678..db3864803 100644
--- a/surfsense_backend/tests/unit/indexing_pipeline/test_migrate_legacy_docs.py
+++ b/surfsense_backend/tests/unit/indexing_pipeline/test_migrate_legacy_docs.py
@@ -42,7 +42,7 @@ async def test_updates_hash_and_type_for_legacy_document(
doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-abc",
- search_space_id=1,
+ workspace_id=1,
)
legacy_hash = compute_identifier_hash("COMPOSIO_GMAIL_CONNECTOR", "msg-abc", 1)
@@ -70,7 +70,7 @@ async def test_noop_when_no_legacy_document_exists(
doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-xyz",
- search_space_id=1,
+ workspace_id=1,
)
result_mock = MagicMock()
@@ -89,7 +89,7 @@ async def test_skips_non_google_doc_types(
doc = make_connector_document(
document_type=DocumentType.SLACK_CONNECTOR,
unique_id="slack-123",
- search_space_id=1,
+ workspace_id=1,
)
await pipeline.migrate_legacy_docs([doc])
@@ -111,7 +111,7 @@ async def test_handles_all_three_google_types(
doc = make_connector_document(
document_type=native_type,
unique_id="id-1",
- search_space_id=1,
+ workspace_id=1,
)
existing = MagicMock(spec=Document)
diff --git a/surfsense_backend/tests/unit/indexing_pipeline/test_prepare_placeholder_dedup.py b/surfsense_backend/tests/unit/indexing_pipeline/test_prepare_placeholder_dedup.py
index 262885880..bee3ab1a4 100644
--- a/surfsense_backend/tests/unit/indexing_pipeline/test_prepare_placeholder_dedup.py
+++ b/surfsense_backend/tests/unit/indexing_pipeline/test_prepare_placeholder_dedup.py
@@ -32,7 +32,7 @@ def _make_connector_doc(**overrides) -> ConnectorDocument:
"source_markdown": "## Some new content",
"unique_id": "file-001",
"document_type": DocumentType.GOOGLE_DRIVE_FILE,
- "search_space_id": 1,
+ "workspace_id": 1,
"connector_id": 42,
"created_by_id": "00000000-0000-0000-0000-000000000001",
}
diff --git a/surfsense_backend/tests/unit/middleware/test_b_filesystem_rm_rmdir_cloud.py b/surfsense_backend/tests/unit/middleware/test_b_filesystem_rm_rmdir_cloud.py
index 898ec3765..b6d7bb10e 100644
--- a/surfsense_backend/tests/unit/middleware/test_b_filesystem_rm_rmdir_cloud.py
+++ b/surfsense_backend/tests/unit/middleware/test_b_filesystem_rm_rmdir_cloud.py
@@ -39,11 +39,11 @@ pytestmark = pytest.mark.unit
def _make_middleware(mode: FilesystemMode = FilesystemMode.CLOUD):
selection = FilesystemSelection(mode=mode)
- resolver = build_backend_resolver(selection, search_space_id=1)
+ resolver = build_backend_resolver(selection, workspace_id=1)
return build_filesystem_mw(
backend_resolver=resolver,
filesystem_mode=mode,
- search_space_id=1,
+ workspace_id=1,
user_id="00000000-0000-0000-0000-000000000001",
thread_id=1,
)
@@ -290,7 +290,7 @@ class TestKBPostgresBackendDeleteFilter:
def _make_backend(self, state: dict[str, Any]) -> KBPostgresBackend:
runtime = SimpleNamespace(state=state)
- return KBPostgresBackend(search_space_id=1, runtime=runtime)
+ return KBPostgresBackend(workspace_id=1, runtime=runtime)
def test_pending_filesystem_view_returns_deleted_paths(self):
backend = self._make_backend(
diff --git a/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py b/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py
index dafda17d2..54696a09a 100644
--- a/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py
+++ b/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py
@@ -38,16 +38,16 @@ def test_backend_resolver_returns_multi_root_backend_for_single_root(tmp_path: P
def test_backend_resolver_uses_cloud_mode_by_default():
resolver = build_backend_resolver(FilesystemSelection())
backend = resolver(_RuntimeStub())
- # When no search_space_id is provided we fall back to StateBackend so
+ # When no workspace_id is provided we fall back to StateBackend so
# sub-agents / tests without DB access still work.
assert backend.__class__.__name__ == "StateBackend"
-def test_backend_resolver_uses_kb_postgres_in_cloud_with_search_space():
- resolver = build_backend_resolver(FilesystemSelection(), search_space_id=42)
+def test_backend_resolver_uses_kb_postgres_in_cloud_with_workspace():
+ resolver = build_backend_resolver(FilesystemSelection(), workspace_id=42)
backend = resolver(_RuntimeStub())
assert backend.__class__.__name__ == "KBPostgresBackend"
- assert backend.search_space_id == 42
+ assert backend.workspace_id == 42
def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path: Path):
diff --git a/surfsense_backend/tests/unit/middleware/test_kb_persistence_filesystem_parity.py b/surfsense_backend/tests/unit/middleware/test_kb_persistence_filesystem_parity.py
index e78db1e76..ddd2b58c7 100644
--- a/surfsense_backend/tests/unit/middleware/test_kb_persistence_filesystem_parity.py
+++ b/surfsense_backend/tests/unit/middleware/test_kb_persistence_filesystem_parity.py
@@ -92,7 +92,7 @@ async def test_create_document_allows_identical_content_at_different_paths() ->
session, # type: ignore[arg-type]
virtual_path="/documents/a/notes.md",
content=content,
- search_space_id=42,
+ workspace_id=42,
created_by_id="user-1",
)
assert isinstance(first, Document)
@@ -104,7 +104,7 @@ async def test_create_document_allows_identical_content_at_different_paths() ->
session, # type: ignore[arg-type]
virtual_path="/documents/b/notes-copy.md",
content=content,
- search_space_id=42,
+ workspace_id=42,
created_by_id="user-1",
)
assert isinstance(second, Document)
@@ -121,7 +121,7 @@ async def test_create_document_still_rejects_path_collision() -> None:
"""Path uniqueness remains the hard invariant.
If ``unique_identifier_hash`` already points at an existing row in
- the same search space, the create call must raise ``ValueError``
+ the same workspace, the create call must raise ``ValueError``
with a clear message — matching the behavior the commit loop relies
on to upsert via the existing-row code path.
"""
@@ -137,7 +137,7 @@ async def test_create_document_still_rejects_path_collision() -> None:
session, # type: ignore[arg-type]
virtual_path="/documents/notes.md",
content="anything",
- search_space_id=42,
+ workspace_id=42,
created_by_id="user-1",
)
@@ -160,7 +160,7 @@ async def test_create_document_does_not_query_for_content_hash_collision(
session, # type: ignore[arg-type]
virtual_path="/documents/notes.md",
content="hello",
- search_space_id=42,
+ workspace_id=42,
created_by_id="user-1",
)
# Path-collision SELECT only. No content_hash SELECT.
diff --git a/surfsense_backend/tests/unit/middleware/test_kb_persistence_revisions.py b/surfsense_backend/tests/unit/middleware/test_kb_persistence_revisions.py
index 023213aaa..bf195d6bb 100644
--- a/surfsense_backend/tests/unit/middleware/test_kb_persistence_revisions.py
+++ b/surfsense_backend/tests/unit/middleware/test_kb_persistence_revisions.py
@@ -217,7 +217,7 @@ async def test_pre_write_snapshot_defers_dispatch_when_list_provided(
session, # type: ignore[arg-type]
doc=doc,
action_id=42,
- search_space_id=1,
+ workspace_id=1,
turn_id="t-1",
deferred_dispatches=deferred,
)
@@ -262,7 +262,7 @@ async def test_pre_write_snapshot_dispatches_inline_when_list_omitted(
session, # type: ignore[arg-type]
doc=doc,
action_id=88,
- search_space_id=1,
+ workspace_id=1,
turn_id="t-1",
# No deferred_dispatches arg — fall back to inline dispatch.
)
@@ -302,7 +302,7 @@ async def test_pre_mkdir_snapshot_defers_dispatch_when_list_provided(
session, # type: ignore[arg-type]
folder=folder,
action_id=55,
- search_space_id=1,
+ workspace_id=1,
turn_id="t-1",
deferred_dispatches=deferred,
)
diff --git a/surfsense_backend/tests/unit/middleware/test_kb_postgres_read.py b/surfsense_backend/tests/unit/middleware/test_kb_postgres_read.py
index 8117a6bdb..e153c0f94 100644
--- a/surfsense_backend/tests/unit/middleware/test_kb_postgres_read.py
+++ b/surfsense_backend/tests/unit/middleware/test_kb_postgres_read.py
@@ -28,7 +28,7 @@ pytestmark = pytest.mark.unit
def _backend(state: dict) -> KBPostgresBackend:
- return KBPostgresBackend(search_space_id=1, runtime=SimpleNamespace(state=state))
+ return KBPostgresBackend(workspace_id=1, runtime=SimpleNamespace(state=state))
def test_render_full_document_uses_full_view_and_registers() -> None:
diff --git a/surfsense_backend/tests/unit/middleware/test_knowledge_tree.py b/surfsense_backend/tests/unit/middleware/test_knowledge_tree.py
index c14eca080..5c4bb7504 100644
--- a/surfsense_backend/tests/unit/middleware/test_knowledge_tree.py
+++ b/surfsense_backend/tests/unit/middleware/test_knowledge_tree.py
@@ -101,7 +101,7 @@ class TestFormatTreeRendering:
docs = [_Row(**spec) for spec in doc_specs]
mw = KnowledgeTreeMiddleware(
- search_space_id=1,
+ workspace_id=1,
filesystem_mode=None, # type: ignore[arg-type]
)
return mw._format_tree(index, docs)
diff --git a/surfsense_backend/tests/unit/notifications/api/test_transform.py b/surfsense_backend/tests/unit/notifications/api/test_transform.py
index 96624fe61..621859d6e 100644
--- a/surfsense_backend/tests/unit/notifications/api/test_transform.py
+++ b/surfsense_backend/tests/unit/notifications/api/test_transform.py
@@ -53,7 +53,7 @@ def _notification(**overrides) -> Notification:
defaults = {
"id": 1,
"user_id": uuid.uuid4(),
- "search_space_id": 3,
+ "workspace_id": 3,
"type": "document_processing",
"title": "Title",
"message": "Message",
diff --git a/surfsense_backend/tests/unit/notifications/service/messages/test_document_processing.py b/surfsense_backend/tests/unit/notifications/service/messages/test_document_processing.py
index 9fc93d3ed..a27d9cdb7 100644
--- a/surfsense_backend/tests/unit/notifications/service/messages/test_document_processing.py
+++ b/surfsense_backend/tests/unit/notifications/service/messages/test_document_processing.py
@@ -10,7 +10,7 @@ pytestmark = pytest.mark.unit
def test_operation_id_encodes_type_and_space():
- """The operation id embeds the document type and search space id."""
+ """The operation id embeds the document type and workspace id."""
op = msg.operation_id("FILE", "report.pdf", 9)
assert op.startswith("doc_FILE_9_")
diff --git a/surfsense_backend/tests/unit/notifications/service/messages/test_insufficient_credits.py b/surfsense_backend/tests/unit/notifications/service/messages/test_insufficient_credits.py
index c5366cce2..a7959af6e 100644
--- a/surfsense_backend/tests/unit/notifications/service/messages/test_insufficient_credits.py
+++ b/surfsense_backend/tests/unit/notifications/service/messages/test_insufficient_credits.py
@@ -9,8 +9,8 @@ from app.notifications.service.messages import insufficient_credits as msg
pytestmark = pytest.mark.unit
-def test_operation_id_encodes_search_space():
- """The operation id embeds the search space id."""
+def test_operation_id_encodes_workspace():
+ """The operation id embeds the workspace id."""
assert msg.operation_id("doc.pdf", 9).startswith("insufficient_credits_9_")
diff --git a/surfsense_backend/tests/unit/observability/test_helpers.py b/surfsense_backend/tests/unit/observability/test_helpers.py
index eafb8b626..00e51e467 100644
--- a/surfsense_backend/tests/unit/observability/test_helpers.py
+++ b/surfsense_backend/tests/unit/observability/test_helpers.py
@@ -26,7 +26,6 @@ def _disable_otel(monkeypatch: pytest.MonkeyPatch):
("delete_folder_documents_background", "delete"),
("delete_search_space_background", "delete"),
("process_extension_document", "process"),
- ("process_youtube_video", "process"),
("process_file_upload", "process"),
("process_file_upload_with_document", "process"),
("process_circleback_meeting", "process"),
@@ -36,7 +35,6 @@ def _disable_otel(monkeypatch: pytest.MonkeyPatch):
("cleanup_stale_indexing_notifications", "cleanup"),
("reconcile_pending_stripe_credit_purchases", "reconcile"),
("check_periodic_schedules", "check"),
- ("ai_sort_search_space", "ai"),
("index_notion_pages", "index"),
("index_github_repos", "index"),
("index_google_drive_files", "index"),
diff --git a/surfsense_backend/tests/unit/observability/test_otel.py b/surfsense_backend/tests/unit/observability/test_otel.py
index d3718e7b9..84c6225e2 100644
--- a/surfsense_backend/tests/unit/observability/test_otel.py
+++ b/surfsense_backend/tests/unit/observability/test_otel.py
@@ -166,11 +166,11 @@ class TestMetricHelpers:
model="gpt-4o",
provider="openai",
)
- metrics.record_tool_call_duration(3.0, tool_name="web_search")
- metrics.record_tool_call_error(tool_name="web_search")
+ metrics.record_tool_call_duration(3.0, tool_name="scrape_webpage")
+ metrics.record_tool_call_error(tool_name="scrape_webpage")
metrics.record_kb_search_duration(
4.0,
- search_space_id=1,
+ workspace_id=1,
surface="documents",
)
metrics.record_compaction_run(reason="auto")
@@ -250,7 +250,7 @@ class TestNoopSpansWhenDisabled:
helpers = [
otel.tool_call_span("write_file", input_size=42),
otel.model_call_span(model_id="openai:gpt-4o", provider="openai"),
- otel.kb_search_span(search_space_id=1, query_chars=99),
+ otel.kb_search_span(workspace_id=1, query_chars=99),
otel.kb_persist_span(document_type="NOTE", document_id=7),
otel.compaction_span(reason="overflow", messages_in=120),
otel.interrupt_span(interrupt_type="permission_ask"),
@@ -278,3 +278,30 @@ class TestEnabledIntegration:
sp.set_attribute("tool.truncated", False)
with otel.model_call_span(model_id="m", provider="p") as sp:
sp.set_attribute("retry.count", 3)
+
+
+class TestPackageVersionResilience:
+ """A version-tag lookup must never crash the request path (e.g. subagents).
+
+ An editable/dynamic install can have distribution metadata with no
+ ``Version`` field, which raises ``KeyError`` deep inside importlib.metadata.
+ ``_package_version`` must swallow that and every other lookup failure.
+ """
+
+ def test_missing_version_key_falls_back(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ def _raise_key_error(_name: str) -> str:
+ raise KeyError("Version")
+
+ monkeypatch.setattr(metrics.metadata, "version", _raise_key_error)
+ assert metrics._package_version() == "unknown"
+
+ def test_package_not_found_falls_back(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ def _raise_not_found(_name: str) -> str:
+ raise metrics.metadata.PackageNotFoundError("surf-new-backend")
+
+ monkeypatch.setattr(metrics.metadata, "version", _raise_not_found)
+ assert metrics._package_version() == "unknown"
diff --git a/surfsense_backend/tests/unit/observability/test_retriever_otel.py b/surfsense_backend/tests/unit/observability/test_retriever_otel.py
index 9712a3150..b98317376 100644
--- a/surfsense_backend/tests/unit/observability/test_retriever_otel.py
+++ b/surfsense_backend/tests/unit/observability/test_retriever_otel.py
@@ -48,14 +48,14 @@ async def test_retriever_wrapper_records_one_span_and_metric(monkeypatch) -> Non
self,
query_text: str,
top_k: int,
- search_space_id: int,
+ workspace_id: int,
) -> list[str]:
- del query_text, top_k, search_space_id
+ del query_text, top_k, workspace_id
return ["doc-1", "doc-2"]
result = await Retriever().search("hello", 3, 42)
assert result == ["doc-1", "doc-2"]
assert len(calls) == 1
- assert calls[0]["search_space_id"] == 42
+ assert calls[0]["workspace_id"] == 42
assert calls[0]["surface"] == "documents"
diff --git a/surfsense_backend/tests/unit/platforms/__init__.py b/surfsense_backend/tests/unit/platforms/__init__.py
new file mode 100644
index 000000000..116a188e2
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/__init__.py
@@ -0,0 +1 @@
+"""Platform-native scraper packages live here (one subpackage per platform)."""
diff --git a/surfsense_backend/tests/unit/platforms/google_maps/__init__.py b/surfsense_backend/tests/unit/platforms/google_maps/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/platforms/google_maps/test_parsers.py b/surfsense_backend/tests/unit/platforms/google_maps/test_parsers.py
new file mode 100644
index 000000000..a0643c8b6
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/google_maps/test_parsers.py
@@ -0,0 +1,175 @@
+"""Offline parser tests against a captured real place ``darray`` fixture.
+
+The fixture is the ``jd[6]`` array from a live ``/maps/preview/place`` RPC
+response for "Kim's Island" (the restaurant used in the Apify output example),
+regenerated by ``scripts/e2e_google_maps_scraper.py``. These tests pin the
+array-index paths so a Google structure shift is caught offline.
+"""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from app.proprietary.platforms.google_maps.fetch import strip_xssi
+from app.proprietary.platforms.google_maps.parsers import (
+ brace_match_json,
+ dig,
+ parse_place,
+)
+
+_FIXTURE = Path(__file__).parent / "fixtures" / "place_darray.json"
+
+
+@pytest.fixture
+def darray() -> list:
+ return json.loads(_FIXTURE.read_text(encoding="utf-8"))
+
+
+def test_parse_place_core_fields(darray):
+ place = parse_place(darray)
+ assert place["title"] == "Kim's Island"
+ assert place["placeId"] == "ChIJJQz5EZzKw4kRCZ95UajbyGw"
+ assert place["fid"] == "0x89c3ca9c11f90c25:0x6cc8dba851799f09"
+ assert place["categoryName"] == "Chinese restaurant"
+ assert "Chinese restaurant" in place["categories"]
+ assert place["totalScore"] == 4.5
+ assert place["phone"] == "(718) 356-5168"
+ assert place["website"] == "http://kimsislandsi.com/"
+ assert place["plusCode"].startswith("GQ62+8M")
+
+
+def test_parse_place_address_components(darray):
+ place = parse_place(darray)
+ assert place["neighborhood"] == "Tottenville"
+ assert place["street"] == "175 Main St"
+ assert place["city"] == "Staten Island"
+ assert place["postalCode"] == "10307"
+ assert place["state"] == "New York"
+ assert place["countryCode"] == "US"
+
+
+def test_parse_place_location_and_hours(darray):
+ place = parse_place(darray)
+ assert place["location"]["lat"] == pytest.approx(40.5107736)
+ assert place["location"]["lng"] == pytest.approx(-74.2482624)
+ hours = place["openingHours"]
+ assert hours and all("day" in h and "hours" in h for h in hours)
+
+
+def test_parse_place_detail_extras(darray):
+ place = parse_place(darray)
+ assert place["kgmid"] == "/g/1tmgdcj8"
+ # cid = decimal of the fid's second hex half
+ assert place["cid"] == str(int("0x6cc8dba851799f09", 16))
+ info = place["additionalInfo"]
+ assert "Accessibility" in info
+ assert {"Wheelchair accessible entrance": True} in info["Accessibility"]
+ # every option is a single {name: bool} pair
+ for entries in info.values():
+ for entry in entries:
+ assert len(entry) == 1
+ assert all(isinstance(v, bool) for v in entry.values())
+
+
+def test_parse_place_session_gated_fields(darray):
+ # These fields only appear when the RPC is sent with an NID session
+ # cookie + the full-page pb selector; the fixture pins that payload.
+ place = parse_place(darray)
+ dist = place["reviewsDistribution"]
+ assert set(dist) == {"oneStar", "twoStar", "threeStar", "fourStar", "fiveStar"}
+ assert place["reviewsCount"] == sum(dist.values())
+ assert place["imagesCount"] > 0
+ assert "All" in place["imageCategories"]
+ assert all(u.startswith("https://") for u in place["imageUrls"])
+ tags = place["reviewsTags"]
+ assert tags and all(
+ isinstance(t["title"], str) and isinstance(t["count"], int) for t in tags
+ )
+ assert len(place["additionalInfo"]) >= 5 # full sections, not just Accessibility
+
+
+def test_parse_place_hotel_fields():
+ # Fixture: live darray for The Plaza (NYC), captured via the detail RPC.
+ fixture = Path(__file__).parent / "fixtures" / "hotel_darray.json"
+ place = parse_place(json.loads(fixture.read_text(encoding="utf-8")))
+ assert place["title"] == "The Plaza"
+ assert place["hotelStars"] == "5 stars"
+ assert place["checkInDate"] < place["checkOutDate"] # ISO dates compare
+ assert place["hotelDescription"]
+ similar = place["similarHotelsNearby"]
+ assert len(similar) >= 3
+ assert all(h["title"] and h["fid"] for h in similar)
+ assert any("hotel" in (h.get("description") or "").lower() for h in similar)
+ ads = place["hotelAds"]
+ assert ads and all(a["url"].startswith("https://") for a in ads)
+ assert any(a.get("price", "").startswith("$") for a in ads)
+
+
+def test_hotel_fields_absent_for_non_hotels(darray):
+ # Kim's Island (restaurant) must not grow hotel fields.
+ place = parse_place(darray)
+ for key in ("hotelStars", "checkInDate", "similarHotelsNearby", "hotelAds"):
+ assert key not in place
+
+
+def test_popular_times_shapes():
+ from app.proprietary.platforms.google_maps.parsers import _popular_times
+
+ darray: list = [None] * 100
+ darray[84] = [
+ [[7, [[6, 0, "", "None", "6 AM", "No wait", "6a"], [7, 35]]]],
+ None,
+ None,
+ None,
+ None,
+ None,
+ "A little busy",
+ [22, 76],
+ ]
+ out = _popular_times(darray)
+ assert out["popularTimesHistogram"] == {
+ "Su": [
+ {"hour": 6, "occupancyPercent": 0},
+ {"hour": 7, "occupancyPercent": 35},
+ ]
+ }
+ assert out["popularTimesLiveText"] == "A little busy"
+ assert out["popularTimesLivePercent"] == 76
+ assert _popular_times([None] * 100) == {}
+
+
+def test_parse_place_reservation_links():
+ fixture = Path(__file__).parent / "fixtures" / "search_response.json"
+ from app.proprietary.platforms.google_maps.fetch import _search_darrays
+
+ jd = json.loads(fixture.read_text(encoding="utf-8"))
+ place = parse_place(_search_darrays(jd)[0])
+ # the first search hit in the fixture carries a reservation provider
+ links = place["tableReservationLinks"]
+ assert all(set(link) == {"url", "source"} for link in links)
+ assert place["reserveTableUrl"] == links[0]["url"]
+
+
+def test_parse_place_omits_missing_fields(darray):
+ # parse_place drops keys whose path missed, rather than emitting nulls.
+ place = parse_place(darray)
+ assert all(v is not None for v in place.values())
+
+
+def test_dig_tolerates_ragged_paths():
+ assert dig([1, [2, 3]], 1, 0) == 2
+ assert dig([1, [2, 3]], 5) is None
+ assert dig([1, None], 1, 0) is None
+ assert dig("not a list", 0) is None
+
+
+def test_strip_xssi_and_brace_match_handle_html_wrapper():
+ # Mirrors the real proxy response: text/plain body wrapped in …
.
+ raw = ')]}\'\n[1,[2,3],"x"]
'
+ body = strip_xssi(raw)
+ blob = brace_match_json(body, body.index("["))
+ assert json.loads(blob) == [1, [2, 3], "x"]
+
+ wrapped = ')]}\'\n[["a"]]'
+ assert strip_xssi(wrapped).startswith("[[")
diff --git a/surfsense_backend/tests/unit/platforms/google_maps/test_reviews.py b/surfsense_backend/tests/unit/platforms/google_maps/test_reviews.py
new file mode 100644
index 000000000..abe6567c8
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/google_maps/test_reviews.py
@@ -0,0 +1,116 @@
+"""Offline tests for the Google Maps reviews parsing + flow helpers.
+
+``fixtures/boq_reviews_page.json`` is a real ``GetLocalBoqProxy`` page (the
+raw review list, ``jd[1][10][2]``) captured by scripts/e2e_google_maps_scraper.py
+step 7. Regenerate it with that script if Google shifts the structure.
+"""
+
+import json
+from datetime import datetime
+from pathlib import Path
+
+import pytest
+
+from app.proprietary.platforms.google_maps.fetch import build_reviews_url
+from app.proprietary.platforms.google_maps.parsers import (
+ parse_review,
+ parse_reviews_page,
+ strip_personal_data,
+)
+from app.proprietary.platforms.google_maps.reviews import _before_cutoff, _keep
+
+_FIXTURE = Path(__file__).parent / "fixtures" / "boq_reviews_page.json"
+
+
+@pytest.fixture
+def reviews_raw() -> list:
+ return json.loads(_FIXTURE.read_text(encoding="utf-8"))
+
+
+def test_parse_reviews_page_full_page(reviews_raw):
+ parsed = parse_reviews_page(reviews_raw)
+ assert len(parsed) == len(reviews_raw) == 10
+ for review in parsed:
+ assert review["name"]
+ assert review["reviewId"]
+ assert 1 <= review["stars"] <= 5
+ assert review["publishedAtDate"].endswith("Z")
+
+
+def test_parse_review_fields(reviews_raw):
+ review = parse_review(reviews_raw[0])
+ assert review["name"] == "Greg"
+ assert review["stars"] == 5
+ assert review["reviewerId"] == "108156147079988470963"
+ assert review["reviewerUrl"].startswith("https://www.google.com/maps/contrib/")
+ assert review["reviewerNumberOfReviews"] == 63
+ assert review["isLocalGuide"] is True
+ assert review["reviewOrigin"] == "Google"
+ assert review["originalLanguage"] == "en"
+ assert review["publishAt"] == "2 months ago"
+ # 1776469524140 ms epoch -> UTC ISO
+ assert review["publishedAtDate"] == "2026-04-17T23:45:24.140Z"
+ assert "spiced" in review["text"]
+ # Guided answers split into context vs per-aspect ratings.
+ assert review["reviewContext"]["Order type"] == "Take out"
+ assert review["reviewDetailedRating"]["Food"] == 5
+
+
+def test_parse_review_owner_response_and_images(reviews_raw):
+ with_reply = [
+ r
+ for r in (parse_review(x) for x in reviews_raw)
+ if r and r.get("responseFromOwnerText")
+ ]
+ assert with_reply, "fixture should contain at least one owner reply"
+ assert with_reply[0]["responseFromOwnerText"]
+
+ with_images = [
+ r
+ for r in (parse_review(x) for x in reviews_raw)
+ if r and r.get("reviewImageUrls")
+ ]
+ assert with_images, "fixture should contain at least one review with photos"
+ assert with_images[0]["reviewImageUrls"][0].startswith("https://")
+
+
+def test_parse_review_rejects_garbage():
+ assert parse_review(None) is None
+ assert parse_review([]) is None
+ assert parse_review([None, 5]) is None # no author block
+
+
+def test_strip_personal_data(reviews_raw):
+ review = parse_review(reviews_raw[0])
+ strip_personal_data(review)
+ for key in ("name", "reviewerId", "reviewerUrl", "reviewerPhotoUrl"):
+ assert key not in review
+ assert review["reviewId"] # non-personal fields stay
+ assert review["stars"] == 5
+
+
+def test_before_cutoff():
+ cutoff = datetime(2026, 3, 1)
+ assert _before_cutoff({"publishedAtDate": "2026-02-01T00:00:00.000Z"}, cutoff)
+ assert not _before_cutoff({"publishedAtDate": "2026-04-01T00:00:00.000Z"}, cutoff)
+ assert not _before_cutoff({}, cutoff) # undated reviews are kept
+
+
+def test_keep_filters():
+ review = {"text": "Great NOODLES here", "reviewOrigin": "Google"}
+ assert _keep(review, filter_string="", origin="all")
+ assert _keep(review, filter_string="noodles", origin="google")
+ assert not _keep(review, filter_string="pizza", origin="all")
+ assert not _keep({"reviewOrigin": "TripAdvisor"}, filter_string="", origin="google")
+
+
+def test_build_reviews_url_shape():
+ fid = "0x89c3ca9c11f90c25:0x6cc8dba851799f09"
+ first = build_reviews_url(fid, sort_code=2)
+ assert "GetLocalBoqProxy" in first
+ assert "hl=en" in first
+ assert fid in json.dumps(first) or "0x89c3ca9c11f90c25" in first
+ # First page requests a page size; later pages carry the token instead.
+ paged = build_reviews_url(fid, sort_code=2, page_token="TOKEN123")
+ assert "TOKEN123" in paged
+ assert paged != first
diff --git a/surfsense_backend/tests/unit/platforms/google_maps/test_search.py b/surfsense_backend/tests/unit/platforms/google_maps/test_search.py
new file mode 100644
index 000000000..6aeb9e8a4
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/google_maps/test_search.py
@@ -0,0 +1,150 @@
+"""Offline tests for the map-search flow: response extraction, URL building,
+and the Apify result filters. The fixture is a real ``search?tbm=map`` response
+captured by scripts/e2e_google_maps_scraper.py (step 9, query "pizza new york").
+"""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from app.proprietary.platforms.google_maps.fetch import (
+ _search_darrays,
+ build_search_url,
+)
+from app.proprietary.platforms.google_maps.parsers import parse_place
+from app.proprietary.platforms.google_maps.schemas import GoogleMapsScrapeInput
+from app.proprietary.platforms.google_maps.scraper import (
+ _custom_point,
+ _location_text,
+ _passes_filters,
+)
+
+_FIXTURE = Path(__file__).parent / "fixtures" / "search_response.json"
+
+
+@pytest.fixture(scope="module")
+def search_jd():
+ return json.loads(_FIXTURE.read_text(encoding="utf-8"))
+
+
+def test_search_darrays_extracts_full_page(search_jd):
+ darrays = _search_darrays(search_jd)
+ assert len(darrays) == 20
+ parsed = [parse_place(d) for d in darrays]
+ for fields in parsed:
+ assert fields["title"]
+ assert fields["fid"].startswith("0x")
+ assert fields["placeId"].startswith("ChIJ")
+ assert "location" in fields
+ fids = [f["fid"] for f in parsed]
+ assert len(set(fids)) == 20 # no dupes within a page
+
+
+def test_search_result_has_detail_fields(search_jd):
+ fields = parse_place(_search_darrays(search_jd)[0])
+ # Search entries carry the full darray, so detail fields come through.
+ assert fields["categories"]
+ assert fields["address"]
+ assert fields["totalScore"] > 0
+ assert fields["city"]
+
+
+def test_search_darrays_rejects_garbage():
+ assert _search_darrays(None) == []
+ assert _search_darrays([]) == []
+ assert _search_darrays([[None, None]]) == []
+
+
+def test_build_search_url_shape():
+ url = build_search_url("pizza new york", offset=20, language="de")
+ assert url.startswith("https://www.google.com/search?tbm=map")
+ assert "hl=de" in url
+ assert "q=pizza%20new%20york" in url
+ assert "!8i20" in url # offset
+ assert "!7i20" in url # page size
+ # whole-earth viewport when no coordinates given
+ assert "!1d25000000" in url
+
+ geo_url = build_search_url("museum", lat=48.85, lng=2.35, radius_m=5000)
+ assert "!2d2.35" in geo_url and "!3d48.85" in geo_url
+ assert "!1d10000" in geo_url # radius -> diameter
+
+
+def test_location_text_prefers_location_query():
+ assert (
+ _location_text(GoogleMapsScrapeInput(locationQuery="Berlin, Germany"))
+ == "Berlin, Germany"
+ )
+ assert (
+ _location_text(
+ GoogleMapsScrapeInput(city="Austin", state="TX", countryCode="US")
+ )
+ == "Austin, TX, US"
+ )
+ assert _location_text(GoogleMapsScrapeInput()) is None
+
+
+def test_custom_point():
+ lat, lng, radius = _custom_point(
+ GoogleMapsScrapeInput(
+ customGeolocation={
+ "type": "Point",
+ "coordinates": [2.35, 48.85],
+ "radiusKm": 5,
+ }
+ )
+ )
+ assert (lat, lng, radius) == (48.85, 2.35, 5000)
+ assert _custom_point(GoogleMapsScrapeInput()) == (None, None, None)
+ assert _custom_point(
+ GoogleMapsScrapeInput(customGeolocation={"type": "Polygon"})
+ ) == (None, None, None)
+
+
+def test_passes_filters():
+ fields = {
+ "title": "Joe's Pizza",
+ "categories": ["Pizza restaurant"],
+ "totalScore": 4.4,
+ "website": "https://joes.example",
+ }
+ default = GoogleMapsScrapeInput()
+ assert _passes_filters(fields, "pizza", default)
+
+ assert not _passes_filters(
+ fields, "pizza", GoogleMapsScrapeInput(searchMatching="only_exact")
+ )
+ assert _passes_filters(
+ fields, "pizza", GoogleMapsScrapeInput(searchMatching="only_includes")
+ )
+ assert not _passes_filters(
+ fields, "burger", GoogleMapsScrapeInput(searchMatching="only_includes")
+ )
+
+ assert _passes_filters(
+ fields, "pizza", GoogleMapsScrapeInput(categoryFilterWords=["pizza"])
+ )
+ assert not _passes_filters(
+ fields, "pizza", GoogleMapsScrapeInput(categoryFilterWords=["barber"])
+ )
+
+ assert not _passes_filters(
+ fields, "pizza", GoogleMapsScrapeInput(placeMinimumStars="fourAndHalf")
+ )
+ assert _passes_filters(
+ fields, "pizza", GoogleMapsScrapeInput(placeMinimumStars="four")
+ )
+
+ assert _passes_filters(
+ fields, "pizza", GoogleMapsScrapeInput(website="withWebsite")
+ )
+ assert not _passes_filters(
+ fields, "pizza", GoogleMapsScrapeInput(website="withoutWebsite")
+ )
+
+ closed = {**fields, "permanentlyClosed": True}
+ assert not _passes_filters(
+ closed, "pizza", GoogleMapsScrapeInput(skipClosedPlaces=True)
+ )
+ assert _passes_filters(closed, "pizza", default)
diff --git a/surfsense_backend/tests/unit/platforms/google_maps/test_skeleton.py b/surfsense_backend/tests/unit/platforms/google_maps/test_skeleton.py
new file mode 100644
index 000000000..4c21cc7fb
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/google_maps/test_skeleton.py
@@ -0,0 +1,97 @@
+"""Offline checks for the Google Maps scraper skeleton.
+
+Covers the pure parts: URL classification and Apify-spec schema defaults/
+serialization. The live flows (place / reviews / search) are covered by the
+e2e scripts and the fixture-based parser tests.
+"""
+
+import pytest
+
+from app.proprietary.platforms.google_maps import (
+ GoogleMapsReviewsInput,
+ GoogleMapsScrapeInput,
+ PlaceItem,
+ ReviewItem,
+)
+from app.proprietary.platforms.google_maps.url_resolver import extract_fid, resolve_url
+
+
+@pytest.mark.parametrize(
+ ("url", "kind", "value"),
+ [
+ (
+ "https://www.google.com/maps/place/Kim's+Island/@40.51,-74.24,17z/data=!4m6",
+ "place",
+ "Kim's Island",
+ ),
+ (
+ "https://www.google.com/maps/search/restaurants/@52.5190603,13.388574,13z/",
+ "search",
+ "restaurants",
+ ),
+ ("https://www.google.com/maps/reviews/data=!4m8!14m7", "reviews", None),
+ (
+ "https://www.google.com/maps?cid=7838756667406262025",
+ "cid",
+ "7838756667406262025",
+ ),
+ ("https://goo.gl/maps/abc123", "shortlink", None),
+ ("https://maps.app.goo.gl/xyz", "shortlink", None),
+ ],
+)
+def test_resolve_url(url, kind, value):
+ resolved = resolve_url(url)
+ assert resolved is not None
+ assert resolved.kind == kind
+ if value is not None:
+ assert resolved.value == value
+
+
+def test_resolve_url_rejects_non_maps():
+ assert resolve_url("https://example.com/maps/place/foo") is None
+ assert resolve_url("https://www.google.com/search?q=pizza") is None
+
+
+def test_extract_fid():
+ url = (
+ "https://www.google.com/maps/place/Kim's+Island/@40.51,-74.24,17z/"
+ "data=!4m6!3m5!1s0x89c3ca9c11f90c25:0x6cc8dba851799f09!8m2"
+ )
+ assert extract_fid(url) == "0x89c3ca9c11f90c25:0x6cc8dba851799f09"
+ assert extract_fid("https://www.google.com/maps/place/Foo") is None
+ assert resolve_url(url).fid == "0x89c3ca9c11f90c25:0x6cc8dba851799f09"
+
+
+def test_scrape_input_defaults_match_apify_spec():
+ inp = GoogleMapsScrapeInput()
+ assert inp.language == "en"
+ assert inp.maxCrawledPlacesPerSearch is None # empty = all places
+ assert inp.searchMatching == "all"
+ assert inp.website == "allPlaces"
+ assert inp.reviewsSort == "newest"
+ assert inp.reviewsOrigin == "all"
+ assert inp.scrapeReviewsPersonalData is True
+ assert inp.maxCompetitorsToAnalyze == 30
+ assert inp.scrapeSocialMediaProfiles.facebooks is False
+ # Unknown fields are accepted (extra="allow") so parity is additive.
+ GoogleMapsScrapeInput(someFutureField=1)
+
+
+def test_reviews_input_defaults_match_apify_spec():
+ inp = GoogleMapsReviewsInput()
+ assert inp.maxReviews == 10_000_000
+ assert inp.reviewsSort == "newest"
+ assert inp.personalData is True
+
+
+def test_output_items_serialize_full_shape():
+ place = PlaceItem(title="Kim's Island", placeId="ChIJx").to_output()
+ assert place["title"] == "Kim's Island"
+ assert place["permanentlyClosed"] is False
+ assert place["categories"] == []
+ assert "reviewsDistribution" in place # unsourced fields still emitted
+
+ review = ReviewItem(reviewId="abc", stars=5).to_output()
+ assert review["stars"] == 5
+ assert review["reviewImageUrls"] == []
+ assert "responseFromOwnerText" in review
diff --git a/surfsense_backend/tests/unit/platforms/google_search/__init__.py b/surfsense_backend/tests/unit/platforms/google_search/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/platforms/google_search/test_browser_loop.py b/surfsense_backend/tests/unit/platforms/google_search/test_browser_loop.py
new file mode 100644
index 000000000..447100b4e
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/google_search/test_browser_loop.py
@@ -0,0 +1,36 @@
+"""The Windows regression this guards: main.py runs the server on a
+SelectorEventLoop (psycopg needs it), and Selector loops cannot spawn
+subprocesses — so patchright's Chromium launch died with NotImplementedError
+on every render. fetch.py now marshals all browser work onto a dedicated
+subprocess-capable loop; this check proves that marshalling works from a
+Selector main loop without paying for a real browser launch.
+"""
+
+import asyncio
+import sys
+
+import pytest
+
+from app.proprietary.platforms.google_search import fetch
+
+
+def test_browser_loop_can_spawn_subprocess_from_selector_loop():
+ async def spawn():
+ proc = await asyncio.create_subprocess_exec(
+ sys.executable,
+ "-c",
+ "print('ok')",
+ stdout=asyncio.subprocess.PIPE,
+ )
+ out, _ = await proc.communicate()
+ return out
+
+ async def main():
+ if sys.platform == "win32":
+ # The original bug: the server's own loop cannot do this.
+ with pytest.raises(NotImplementedError):
+ await spawn()
+ # The fix: the same work marshalled onto the browser loop succeeds.
+ assert b"ok" in await fetch._in_browser_loop(spawn())
+
+ asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop)
diff --git a/surfsense_backend/tests/unit/platforms/google_search/test_fetch_concurrency.py b/surfsense_backend/tests/unit/platforms/google_search/test_fetch_concurrency.py
new file mode 100644
index 000000000..c129f7c5f
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/google_search/test_fetch_concurrency.py
@@ -0,0 +1,63 @@
+"""The production regression this guards: several scrape runs render SERPs
+concurrently on the shared browser. When one render failed, `_drop_session`
+closed the browser immediately — under the sibling renders — so every
+in-flight fetch died with TargetClosedError and the runs cascaded into
+"exhausted 24 IPs". The drop must defer the actual close until the last
+in-flight render on that session finishes.
+"""
+
+import asyncio
+
+from app.proprietary.platforms.google_search import fetch
+
+
+class _FakeSession:
+ def __init__(self):
+ self.release = asyncio.Event()
+ self.closed = 0
+
+ async def fetch(self, url, proxy=None):
+ await self.release.wait()
+ return "page"
+
+ async def close(self):
+ self.closed += 1
+
+
+def test_drop_defers_close_until_inflight_renders_finish(monkeypatch):
+ session = _FakeSession()
+
+ async def fake_get_session(mobile):
+ return session
+
+ monkeypatch.setattr(fetch, "_get_session", fake_get_session)
+ monkeypatch.setitem(fetch._sessions, False, session)
+
+ async def main():
+ render = asyncio.create_task(fetch._render_on_loop("u", None, False))
+ await asyncio.sleep(0) # let the render register as in-flight
+ assert fetch._inflight[session] == 1
+
+ await fetch._drop_session_on_loop(False)
+ assert session.closed == 0, "must not close under an in-flight render"
+ assert session in fetch._doomed
+ assert False not in fetch._sessions # next fetch relaunches
+
+ session.release.set()
+ assert await render == "page"
+ assert session.closed == 1, "last render out closes the doomed browser"
+ assert session not in fetch._inflight
+ assert session not in fetch._doomed
+
+ asyncio.run(main())
+
+
+def test_drop_closes_immediately_when_idle():
+ session = _FakeSession()
+
+ async def main():
+ fetch._sessions[False] = session
+ await fetch._drop_session_on_loop(False)
+ assert session.closed == 1
+
+ asyncio.run(main())
diff --git a/surfsense_backend/tests/unit/platforms/google_search/test_skeleton.py b/surfsense_backend/tests/unit/platforms/google_search/test_skeleton.py
new file mode 100644
index 000000000..41a960a0e
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/google_search/test_skeleton.py
@@ -0,0 +1,534 @@
+"""Offline checks for the Google Search results scraper.
+
+Covers the pure parts (no network): queries classification, search-operator
+folding, URL building, Apify-spec schema defaults/serialization, and parsing a
+rendered SERP into result blocks (against a compact synthetic fixture). The
+live fetch / AI Mode flows are exercised by the e2e script, not here.
+"""
+
+from datetime import UTC, datetime
+
+from app.proprietary.platforms.google_search import (
+ GoogleSearchScrapeInput,
+ SerpItem,
+)
+from app.proprietary.platforms.google_search.parsers import parse_serp
+from app.proprietary.platforms.google_search.query_builder import (
+ augment_query,
+ build_search_url,
+ parse_queries,
+ resolve_date,
+ term_from_url,
+)
+
+
+def test_parse_queries_classifies_terms_and_urls():
+ entries = parse_queries(
+ "best SEO tools\n"
+ "\n" # blank lines are skipped
+ " https://www.google.com/search?q=apify+web+scraping \n"
+ "javascript OR python site:stackoverflow.com\n"
+ "https://example.com/search?q=not-google\n"
+ )
+ assert [(e.kind, e.value) for e in entries] == [
+ ("term", "best SEO tools"),
+ ("url", "https://www.google.com/search?q=apify+web+scraping"),
+ ("term", "javascript OR python site:stackoverflow.com"),
+ # non-Google URLs are treated as literal search terms, not scrape URLs
+ ("term", "https://example.com/search?q=not-google"),
+ ]
+
+
+def test_term_from_url():
+ assert term_from_url("https://www.google.com/search?q=apify+scraping") == (
+ "apify scraping"
+ )
+ assert term_from_url("https://www.google.com/search") is None
+
+
+def test_augment_query_folds_all_filters():
+ inp = GoogleSearchScrapeInput(
+ queries="x",
+ forceExactMatch=True,
+ site="allrecipes.com",
+ relatedToSite="ignored.com", # site: wins
+ wordsInTitle=["easy apple", "pie"],
+ wordsInText=["cinnamon"],
+ wordsInUrl=["recipe"],
+ fileTypes=["pdf", "doc"],
+ beforeDate="2024-12-31",
+ afterDate="2024-01-01",
+ )
+ assert augment_query("apple pie", inp) == (
+ '"apple pie" site:allrecipes.com intitle:"easy apple" intitle:pie '
+ "intext:cinnamon inurl:recipe filetype:pdf OR filetype:doc "
+ "before:2024-12-31 after:2024-01-01"
+ )
+
+
+def test_augment_query_related_used_when_no_site():
+ inp = GoogleSearchScrapeInput(queries="x", relatedToSite="example.com")
+ assert augment_query("q", inp) == "q related:example.com"
+
+
+def test_resolve_date_absolute_and_relative():
+ assert resolve_date("2024-05-03") == "2024-05-03"
+ now = datetime(2026, 7, 3, tzinfo=UTC)
+ assert resolve_date("8 days", now=now) == "2026-06-25"
+ assert resolve_date("3 months", now=now) == "2026-04-04"
+ assert resolve_date("1 year", now=now) == "2025-07-03"
+ assert resolve_date("someday") is None
+
+
+def test_build_search_url_localization_and_paging():
+ inp = GoogleSearchScrapeInput(
+ queries="x",
+ countryCode="ES",
+ searchLanguage="de",
+ languageCode="en",
+ locationUule="w+CAIQICIhVW5pdGVkIFN0YXRlcyx1c2E=",
+ quickDateRange="m6",
+ includeUnfilteredResults=True,
+ )
+ url = build_search_url("hotels in Seattle", inp, page=2)
+ assert url.startswith("https://www.google.com/search?q=hotels+in+Seattle")
+ assert "start=10" in url
+ assert "gl=es" in url
+ assert "lr=lang_de" in url
+ assert "hl=en" in url
+ assert "uule=w%2BCAIQICIhVW5pdGVkIFN0YXRlcyx1c2E%3D" in url
+ assert "tbs=qdr%3Am6" in url
+ assert "filter=0" in url
+
+ plain = build_search_url("q", GoogleSearchScrapeInput(queries="x"))
+ assert "start=" not in plain # page 1 carries no offset
+
+
+def test_scrape_input_defaults_match_apify_spec():
+ inp = GoogleSearchScrapeInput(queries="best SEO tools")
+ assert inp.maxPagesPerQuery is None # unset = 1 page
+ assert inp.aiOverview.scrapeFullAiOverview is False
+ assert inp.aiModeSearch.enableAiMode is False
+ assert inp.focusOnPaidAds is False
+ assert inp.forceExactMatch is False
+ assert inp.mobileResults is False
+ assert inp.saveHtml is False
+ assert inp.saveHtmlToKeyValueStore is True # actor default is ON
+ assert inp.includeIcons is False
+ # Excluded other-actor add-ons are still accepted (extra="allow") so a
+ # verbatim Apify payload validates; they are ignored, not modeled.
+ GoogleSearchScrapeInput(
+ queries="q",
+ perplexitySearch={"enablePerplexity": True},
+ chatGptSearch={"enableChatGpt": True},
+ maximumLeadsEnrichmentRecords=5,
+ )
+
+
+def test_output_item_serializes_full_shape():
+ item = SerpItem(resultsTotal=42).to_output()
+ assert item["resultsTotal"] == 42
+ assert item["organicResults"] == []
+ assert item["paidResults"] == []
+ assert item["relatedQueries"] == []
+ assert item["peopleAlsoAsk"] == []
+ assert item["aiModeResult"] is None # unsourced fields still emitted
+ assert item["searchQuery"]["device"] == "DESKTOP"
+ assert item["searchQuery"]["type"] == "SEARCH"
+
+
+# Compact stand-in for a rendered SERP: the selectors parse_serp relies on,
+# without the ~1 MB of a live capture. If Google's layout drifts, the fix is in
+# parsers.py's selector constants; this fixture pins the expected extraction.
+_SERP_FIXTURE = """
+
+ About 1,230 results (0.42 seconds)
+
+
+
+
+
+
+ All our recipes ...
+
+
+
+
+
+
+
+
+
+
+
+
Homemade Apple Pie 9-inch
+
Pie Shop
+
$24.99
+
$30
+
+
+
+
+
+
Apple pie is a classic dessert.Wiki +3
+
+
+
+
+ A short history of pie.
+
+
+
+
+
+
+
+
+
+"""
+
+
+def test_parse_serp_extracts_all_blocks():
+ item = parse_serp(_SERP_FIXTURE)
+
+ assert item.resultsTotal == 1230
+
+ assert len(item.organicResults) == 2
+ first = item.organicResults[0]
+ assert first.position == 1
+ assert first.title == "The Example Guide"
+ assert first.url == "https://example.com/guide"
+ assert first.displayedUrl == "https://example.com"
+ assert first.date == "Jul 2, 2025"
+ assert first.emphasizedKeywords == ["apple pie"]
+ # The leading date is stripped from the snippet.
+ assert first.description == "Learn apple pie the easy way."
+ # Sitelinks come from the sibling table inside this result's card.
+ assert [(s.title, s.url) for s in first.siteLinks] == [
+ ("Recipes", "https://example.com/recipes"),
+ ("About", "https://example.com/about"),
+ ]
+ assert first.siteLinks[0].description == "All our recipes ..."
+ assert first.siteLinks[1].description is None
+
+ second = item.organicResults[1]
+ assert second.date is None
+ assert second.displayedUrl is None # cite without an http head
+ assert second.siteLinks == [] # no card of its own
+
+ # Icons are opt-in: absent by default, the inlined data URI when asked.
+ assert first.icon is None
+ with_icons = parse_serp(_SERP_FIXTURE, include_icons=True)
+ assert with_icons.organicResults[0].icon == "data:image/png;base64,iVBORfake"
+ assert with_icons.organicResults[1].icon is None # block carries no favicon
+
+ # Text ad: heading is the title, the anchor is the clean landing URL, and
+ # the non-heading .Va3FIb is the description (not the title echo).
+ assert len(item.paidResults) == 1
+ ad = item.paidResults[0]
+ assert ad.title == "Buy Apple Pie Online"
+ assert ad.url == "https://shop.example/lp"
+ assert ad.displayedUrl == "https://shop.example"
+ assert ad.description == "Fresh pies delivered daily. Order now and save 20%."
+ assert ad.adPosition == 1
+
+ # Product ad: title, merchant, domain, and both prices.
+ assert len(item.paidProducts) == 1
+ prod = item.paidProducts[0]
+ assert prod.title == "Homemade Apple Pie 9-inch"
+ assert prod.url == "https://pieshop.example/p/123"
+ assert prod.displayedUrl == "pieshop.example"
+ assert prod.description == "Pie Shop"
+ assert prod.prices == ["$24.99", "$30"]
+
+ # Related searches exclude the numeric pagination anchor (a.fl).
+ assert [r.title for r in item.relatedQueries] == [
+ "easy apple pie",
+ "apple pie recipe",
+ ]
+ assert (
+ item.relatedQueries[0].url == "https://www.google.com/search?q=easy+apple+pie"
+ )
+
+ # suggestedResults are the related queries re-shaped with type/position.
+ assert [(s.position, s.title, s.type) for s in item.suggestedResults] == [
+ (1, "easy apple pie", "organic"),
+ (2, "apple pie recipe", "organic"),
+ ]
+ assert item.suggestedResults[0].url == item.relatedQueries[0].url
+
+ assert [p.question for p in item.peopleAlsoAsk] == [
+ "What is apple pie?",
+ "How to bake?",
+ "Why bake?",
+ ]
+ # Snippet-style answer: text + single source link (highlight fragment cut).
+ snippet = item.peopleAlsoAsk[0]
+ assert snippet.answer == "A pie with an apple filling."
+ assert snippet.url == "https://pies.example/apple"
+ assert snippet.title == "Apple pie - Pies"
+ # AI-style answer: paragraphs joined, inline source chips stripped.
+ ai = item.peopleAlsoAsk[1]
+ assert ai.answer == "Preheat the oven. Bake until golden."
+ assert ai.url is None and ai.title is None
+ # Collapsed (never-expanded) question stays question-only.
+ assert item.peopleAlsoAsk[2].answer is None
+
+ # AI Overview: prose (chips stripped) + bullet, sources deduped by URL.
+ aio = item.aiOverview
+ assert aio is not None
+ assert aio.content == "Apple pie is a classic dessert. Best served warm."
+ assert len(aio.sources) == 1
+ src = aio.sources[0]
+ assert src.title == "Pie History - Pies.example"
+ assert src.url == "https://pies.example/history"
+ assert src.description == "A short history of pie."
+ assert src.imageUrl == "https://thumbs.example/pie.jpg"
+
+
+def test_parse_serp_empty_page_is_safe():
+ item = parse_serp("nothing here")
+ assert item.resultsTotal is None
+ assert item.organicResults == []
+ assert item.paidResults == []
+ assert item.paidProducts == []
+ assert item.relatedQueries == []
+ assert item.peopleAlsoAsk == []
+ assert item.aiOverview is None
+
+
+def test_ai_overview_inside_paa_pair_is_not_page_overview():
+ # An expanded PAA question embeds the same widget; it must stay the pair's
+ # answer, not leak into the page-level aiOverview.
+ html = """
+
+
+
+ """
+ item = parse_serp(html)
+ assert item.aiOverview is None
+ assert item.peopleAlsoAsk[0].answer == "Pie is dessert."
+
+
+# Mobile lightweight layout (phone UA render): Gx5Zad blocks, /url? redirect
+# anchors, pre-loaded PAA accordions, clamped AI Overview. Mirrors a Jul 2026
+# live capture, compacted.
+_MOBILE_FIXTURE = """
+
+
+
AI Overview
+
Pie is a baked dish.
+
+
+
+
+
+
+"""
+
+
+def test_parse_serp_mobile_layout():
+ item = parse_serp(_MOBILE_FIXTURE)
+
+ assert len(item.organicResults) == 1
+ org = item.organicResults[0]
+ assert org.title == "Apple Pie Recipe"
+ assert org.url == "https://pies.example/apple" # redirect unwrapped
+ assert org.displayedUrl == "pies.example › apple" # noqa: RUF001 - Google's breadcrumb char
+ assert org.date == "Jun 14, 2026"
+ assert org.description == "The best apple pie recipe."
+ assert org.position == 1
+
+ assert [r.title for r in item.relatedQueries] == ["easy pie"]
+ assert item.relatedQueries[0].url.startswith("https://www.google.com/search")
+ assert item.suggestedResults[0].title == "easy pie"
+
+ paa = item.peopleAlsoAsk[0]
+ assert paa.question == "What is pie?"
+ assert paa.answer == "A pie is a baked dish."
+ assert paa.url == "https://pies.example/what"
+ assert paa.title == "What is pie - Pies"
+
+ aio = item.aiOverview
+ assert aio is not None
+ # Prose + expansion joined, Show more/less chrome stripped.
+ assert aio.content == "Pie is a baked dish. Best served warm. Pie History"
+ assert [s.url for s in aio.sources] == ["https://pies.example/history"]
+ assert aio.sources[0].title == "Pie History"
+
+
+# Google AI Mode page (udm=50): the conversational answer streams into the
+# [data-subtree='aimc'] container, built from the same blocks as the AI
+# Overview (n6owBd paragraphs, Z1qcYe bullets, h7wxwc sources).
+_AI_MODE_FIXTURE = """
+
+
+
Quantum computing uses qubits.IBM +2
+
Superposition: both at once.
+
+
+
+ Quantum computing, defined.
+
+
+
+
+"""
+
+
+def test_parse_ai_mode():
+ from app.proprietary.platforms.google_search.parsers import parse_ai_mode
+
+ result = parse_ai_mode(
+ _AI_MODE_FIXTURE, query="what is quantum computing", url="https://g/x"
+ )
+ assert result is not None
+ assert result.engine == "AI Mode" and result.provider == "Google"
+ assert result.query == "what is quantum computing"
+ assert result.url == "https://g/x"
+ # Prose + bullets joined, source chips stripped.
+ assert result.text == "Quantum computing uses qubits. Superposition: both at once."
+ assert len(result.sources) == 1
+ src = result.sources[0]
+ assert src.title == "What Is Quantum Computing? | IBM"
+ assert src.url == "https://www.ibm.com/think/topics/quantum-computing"
+ assert src.description == "Quantum computing, defined."
+
+ # A page without the answer container (e.g. generation failed) is None.
+ assert parse_ai_mode("", query="q", url="u") is None
+
+
+async def test_ai_mode_flow_emits_item(monkeypatch):
+ from app.proprietary.platforms.google_search import scraper
+
+ async def fake_fetch(url, *, mobile=False):
+ # SERP flow gets a plain SERP; the AI Mode flow's udm=50 URL gets
+ # the AI Mode page.
+ return _AI_MODE_FIXTURE if "udm=50" in url else _NO_ADS_FIXTURE
+
+ monkeypatch.setattr(scraper, "fetch_serp_html", fake_fetch)
+
+ items = await scraper.scrape_serps(
+ GoogleSearchScrapeInput(
+ queries="what is quantum computing",
+ aiModeSearch={"enableAiMode": True},
+ )
+ )
+ assert len(items) == 2 # SERP item + AI Mode item
+ ai_item = items[1]
+ assert ai_item["aiModeResult"]["text"].startswith("Quantum computing")
+ assert ai_item["aiModeResult"]["query"] == "what is quantum computing"
+ assert "udm=50" in ai_item["searchQuery"]["url"]
+ assert ai_item["organicResults"] == []
+
+
+# An organic-only page (no ad blocks) for the focusOnPaidAds retry test.
+_NO_ADS_FIXTURE = """
+
+"""
+
+
+async def test_focus_on_paid_ads_retries_until_ads(monkeypatch):
+ from app.proprietary.platforms.google_search import scraper
+
+ # First two renders have no ads, the third does; focusOnPaidAds should keep
+ # re-rendering and return the ad-bearing page.
+ pages = iter([_NO_ADS_FIXTURE, _NO_ADS_FIXTURE, _SERP_FIXTURE])
+ calls = 0
+
+ async def fake_fetch(_url, *, mobile=False):
+ nonlocal calls
+ calls += 1
+ return next(pages)
+
+ monkeypatch.setattr(scraper, "fetch_serp_html", fake_fetch)
+
+ items = await scraper.scrape_serps(
+ GoogleSearchScrapeInput(queries="car insurance", focusOnPaidAds=True), limit=1
+ )
+ assert calls == 3 # retried past the two ad-less renders
+ assert items[0]["paidResults"], "should return the ad-bearing SERP"
+
+
+async def test_no_focus_takes_first_render(monkeypatch):
+ from app.proprietary.platforms.google_search import scraper
+
+ calls = 0
+
+ async def fake_fetch(_url, *, mobile=False):
+ nonlocal calls
+ calls += 1
+ return _NO_ADS_FIXTURE
+
+ monkeypatch.setattr(scraper, "fetch_serp_html", fake_fetch)
+
+ items = await scraper.scrape_serps(
+ GoogleSearchScrapeInput(queries="anything"), limit=1
+ )
+ assert calls == 1 # no retry without focusOnPaidAds
+ assert items[0]["paidResults"] == []
+ assert items[0]["organicResults"]
diff --git a/surfsense_backend/tests/unit/platforms/reddit/__init__.py b/surfsense_backend/tests/unit/platforms/reddit/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_comment.json b/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_comment.json
new file mode 100644
index 000000000..e8d1d60aa
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_comment.json
@@ -0,0 +1 @@
+{"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "oprc6qa", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "hannah_G_", "can_mod_post": false, "created_utc": 1780597457.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 4, "author_fullname": "t2_gjk10lou", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "**db-git**\u00a0\\- keep your local database in sync with your git branches.\n\n# What My Project Does\n\n`db-git`\u00a0is a developer tool for projects where database state follows code changes: schema migrations, seed data, experimental feature work, and branch switching during reviews. It installs git\u00a0`post-checkout`\u00a0hook and keeps your local database aligned with the branch you are working on.\n\n* Two workflows:\n * `shared`: one database, saved and restored per branch\n * `per-branch`: one database per branch\n* PostgreSQL support today, with plans for more database backends\n* Two PostgreSQL snapshot strategies:\n * `template`: fast database clones using\u00a0`CREATE DATABASE ... TEMPLATE`\n * `pgdump`: portable snapshots using\u00a0`pg_dump`\u00a0and\u00a0`pg_restore`\n\n# Target Audience\n\nBackend and full-stack developers who run databases locally and switch branches often, especially on projects where migrations or seed data diverge between branches. It's a local development tool.\n\n# Comparison\n\nThe main things that set\u00a0`db-git`\u00a0apart from existing tools are:\n\n1. It lets you choose per project,\u00a0`shared`\u00a0vs\u00a0`per-branch`, and\u00a0`template`\u00a0vs\u00a0`pgdump`.\n2. It ties database state directly to checkout.\n3. It is not tied to a specific database engine. PostgreSQL is the first supported backend, but the design isn't Postgres-specific, and more databases are planned.\n\n`uv tool install db-git`\n\nGitHub:\u00a0[https://github.com/earthcomfy/db-git](https://github.com/earthcomfy/db-git)\n\nAny feedback is very welcome!", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_oprc6qa", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "db-git \u00a0- keep your local database in sync with your git branches.
\n\n
What My Project Does \n\n
db-git\u00a0is a developer tool for projects where database state follows code changes: schema migrations, seed data, experimental feature work, and branch switching during reviews. It installs git\u00a0post-checkout\u00a0hook and keeps your local database aligned with the branch you are working on.
\n\n
\nTwo workflows:\n\n\nshared: one database, saved and restored per branch \nper-branch: one database per branch \n \nPostgreSQL support today, with plans for more database backends \nTwo PostgreSQL snapshot strategies:\n\n\ntemplate: fast database clones using\u00a0CREATE DATABASE ... TEMPLATE \npgdump: portable snapshots using\u00a0pg_dump\u00a0and\u00a0pg_restore \n \n \n\n
Target Audience \n\n
Backend and full-stack developers who run databases locally and switch branches often, especially on projects where migrations or seed data diverge between branches. It's a local development tool.
\n\n
Comparison \n\n
The main things that set\u00a0db-git\u00a0apart from existing tools are:
\n\n
\nIt lets you choose per project,\u00a0shared\u00a0vs\u00a0per-branch, and\u00a0template\u00a0vs\u00a0pgdump. \nIt ties database state directly to checkout. \nIt is not tied to a specific database engine. PostgreSQL is the first supported backend, but the design isn't Postgres-specific, and more databases are planned. \n \n\n
uv tool install db-git
\n\n
GitHub:\u00a0https://github.com/earthcomfy/db-git
\n\n
Any feedback is very welcome!
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/oprc6qa/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780597457.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 4}}
\ No newline at end of file
diff --git a/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_listing.json b/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_listing.json
new file mode 100644
index 000000000..d984552a9
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_listing.json
@@ -0,0 +1 @@
+{"kind": "Listing", "data": {"after": "t3_1ugsdwl", "dist": 25, "modhash": "", "geo_filter": null, "children": [{"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "Post all of your code/projects/showcases/AI slop here. \n\nRecycles once a month.", "author_fullname": "t2_6l4z3", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Showcase Thread", "link_flair_richtext": [{"e": "text", "t": "Showcase"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "showcase", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1tws1w7", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.89, "author_flair_background_color": null, "subreddit_type": "public", "ups": 29, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Showcase", "can_mod_post": false, "score": 29, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": true, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1780589106.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Post all of your code/projects/showcases/AI slop here.
\n\n
Recycles once a month.
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "f35fb004-c1ff-11ee-8305-565bc5d0cc73", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#ff66ac", "id": "1tws1w7", "is_robot_indexable": true, "report_reasons": null, "author": "AutoModerator", "discussion_type": null, "num_comments": 207, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/", "stickied": true, "url": "https://www.reddit.com/r/Python/comments/1tws1w7/showcase_thread/", "subreddit_subscribers": 1493425, "created_utc": 1780589106.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "# Weekly Thread: Resource Request and Sharing \ud83d\udcda\n\nStumbled upon a useful Python resource? Or are you looking for a guide on a specific topic? Welcome to the Resource Request and Sharing thread!\n\n## How it Works:\n\n1. **Request**: Can't find a resource on a particular topic? Ask here!\n2. **Share**: Found something useful? Share it with the community.\n3. **Review**: Give or get opinions on Python resources you've used.\n\n## Guidelines:\n\n* Please include the type of resource (e.g., book, video, article) and the topic.\n* Always be respectful when reviewing someone else's shared resource.\n\n## Example Shares:\n\n1. **Book**: [\"Fluent Python\"](https://www.amazon.com/Fluent-Python-Concise-Effective-Programming/dp/1491946008) \\- Great for understanding Pythonic idioms.\n2. **Video**: [Python Data Structures](https://www.youtube.com/watch?v=pkYVOmU3MgA) \\- Excellent overview of Python's built-in data structures.\n3. **Article**: [Understanding Python Decorators](https://realpython.com/primer-on-python-decorators/) \\- A deep dive into decorators.\n\n## Example Requests:\n\n1. **Looking for**: Video tutorials on web scraping with Python.\n2. **Need**: Book recommendations for Python machine learning.\n\nShare the knowledge, enrich the community. Happy learning! \ud83c\udf1f", "author_fullname": "t2_6l4z3", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Saturday Daily Thread: Resource Request and Sharing! Daily Thread", "link_flair_richtext": [{"a": ":pythonLogo:", "e": "emoji", "u": "https://emoji.redditmedia.com/8yxdpg6xxnr71_t5_2qh0y/pythonLogo"}, {"e": "text", "t": " Daily Thread"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "daily-thread", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1umu29k", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 1.0, "author_flair_background_color": null, "subreddit_type": "public", "ups": 7, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": ":pythonLogo: Daily Thread", "can_mod_post": false, "score": 7, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": true, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1783123215.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Weekly Thread: Resource Request and Sharing \ud83d\udcda \n\n
Stumbled upon a useful Python resource? Or are you looking for a guide on a specific topic? Welcome to the Resource Request and Sharing thread!
\n\n
How it Works: \n\n
\nRequest : Can't find a resource on a particular topic? Ask here! \nShare : Found something useful? Share it with the community. \nReview : Give or get opinions on Python resources you've used. \n \n\n
Guidelines: \n\n
\nPlease include the type of resource (e.g., book, video, article) and the topic. \nAlways be respectful when reviewing someone else's shared resource. \n \n\n
Example Shares: \n\n
\nBook : "Fluent Python" - Great for understanding Pythonic idioms. \nVideo : Python Data Structures - Excellent overview of Python's built-in data structures. \nArticle : Understanding Python Decorators - A deep dive into decorators. \n \n\n
Example Requests: \n\n
\nLooking for : Video tutorials on web scraping with Python. \nNeed : Book recommendations for Python machine learning. \n \n\n
Share the knowledge, enrich the community. Happy learning! \ud83c\udf1f
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?auto=webp&s=8cdb17f0919f23f3fc3c0bd9dac21cd40118adda", "width": 1910, "height": 1000}, "resolutions": [{"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=108&crop=smart&auto=webp&s=c7ef9713fb4fbf51d0d7da30fb558f95324a395b", "width": 108, "height": 56}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=216&crop=smart&auto=webp&s=70f4ef0366eafa569960666b4537977954dc4da4", "width": 216, "height": 113}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=320&crop=smart&auto=webp&s=e88e6f574ea2b6abf3644be5140a1ed8ad6d613c", "width": 320, "height": 167}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=640&crop=smart&auto=webp&s=290ace7209dd3df0a237ec970a6a8b1662d523e1", "width": 640, "height": 335}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=960&crop=smart&auto=webp&s=421952297faebb04d1038184216c053ab1f0bb56", "width": 960, "height": 502}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=1080&crop=smart&auto=webp&s=2e3704dd3e397c6dbebe004c6cce33e8cd82d316", "width": 1080, "height": 565}], "variants": {}, "id": "wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "6c024934-de3f-11ea-a05a-0ea86b2be9a1", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#00a6a5", "id": "1umu29k", "is_robot_indexable": true, "report_reasons": null, "author": "AutoModerator", "discussion_type": null, "num_comments": 2, "send_replies": false, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1umu29k/saturday_daily_thread_resource_request_and/", "stickied": true, "url": "https://www.reddit.com/r/Python/comments/1umu29k/saturday_daily_thread_resource_request_and/", "subreddit_subscribers": 1493425, "created_utc": 1783123215.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "I have this really messy code scattered in 5 files, it s not that long abt 4000 lines. \nadding a new feature started feeling like a pain, \nI hate to deliver this tomorrow, and it s not meant to be scalable. \nshould i refactor or add the features ? \n\nor just add the features and keep the code structure unchanged", "author_fullname": "t2_2h7k9mb406", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "refactor or don't touch", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1umxiti", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.39, "author_flair_background_color": null, "subreddit_type": "public", "ups": 0, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 0, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1783133390.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "I have this really messy code scattered in 5 files, it s not that long abt 4000 lines. \nadding a new feature started feeling like a pain, \nI hate to deliver this tomorrow, and it s not meant to be scalable. \nshould i refactor or add the features ?
\n\n
or just add the features and keep the code structure unchanged
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": true, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1umxiti", "is_robot_indexable": true, "report_reasons": null, "author": "Negative_Pay_2940", "discussion_type": null, "num_comments": 19, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1umxiti/refactor_or_dont_touch/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1umxiti/refactor_or_dont_touch/", "subreddit_subscribers": 1493425, "created_utc": 1783133390.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "I wrote [a practical article](https://modernpython.io/serving-a-frontend-with-fastapi-a-practical-guide/) about FastAPI's `app.frontend()` feature.\n\nThe interesting bit is that it serves static frontend build output as low-priority routes, so normal FastAPI API endpoints still win.\n\nThe article covers:\n\n* `app.frontend(\"/\", directory=\"dist\")`\n* SPA fallback with `fallback=\"index.html\"`\n* how it differs from `StaticFiles`\n* serving under a prefix with `APIRouter`\n* a complete mini dashboard example with FastAPI + vanilla JS\n\n", "author_fullname": "t2_2g9w5mkd56", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "FastAPI app.frontend(): serving a frontend build from the same Python app", "link_flair_richtext": [{"e": "text", "t": "News"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "news", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uleil4", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.87, "author_flair_background_color": null, "subreddit_type": "public", "ups": 43, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "News", "can_mod_post": false, "score": 43, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1782988324.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "I wrote a practical article about FastAPI's app.frontend() feature.
\n\n
The interesting bit is that it serves static frontend build output as low-priority routes, so normal FastAPI API endpoints still win.
\n\n
The article covers:
\n\n
\napp.frontend("/", directory="dist") \nSPA fallback with fallback="index.html" \nhow it differs from StaticFiles \nserving under a prefix with APIRouter \na complete mini dashboard example with FastAPI + vanilla JS \n \n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/W-WAtZE5DKslM0Jc7ztGVYI5ALKI9kdfdg2SO7Is-Gk.png?auto=webp&s=da9c531b534cd2ce2779c8005b7153f7f653124e", "width": 1200, "height": 800}, "resolutions": [{"url": "https://external-preview.redd.it/W-WAtZE5DKslM0Jc7ztGVYI5ALKI9kdfdg2SO7Is-Gk.png?width=108&crop=smart&auto=webp&s=8eabae27f78ffb0229c98205ef3c572e76984bf4", "width": 108, "height": 72}, {"url": "https://external-preview.redd.it/W-WAtZE5DKslM0Jc7ztGVYI5ALKI9kdfdg2SO7Is-Gk.png?width=216&crop=smart&auto=webp&s=f9e91bf257102fca1a361ceb8a0787edcffcf92c", "width": 216, "height": 144}, {"url": "https://external-preview.redd.it/W-WAtZE5DKslM0Jc7ztGVYI5ALKI9kdfdg2SO7Is-Gk.png?width=320&crop=smart&auto=webp&s=991b2400e6f7a30ba2840c5a2863a0c6b26c11b4", "width": 320, "height": 213}, {"url": "https://external-preview.redd.it/W-WAtZE5DKslM0Jc7ztGVYI5ALKI9kdfdg2SO7Is-Gk.png?width=640&crop=smart&auto=webp&s=6a2d8086326a1d4535d2dceaaf57c682c3937cc7", "width": 640, "height": 426}, {"url": "https://external-preview.redd.it/W-WAtZE5DKslM0Jc7ztGVYI5ALKI9kdfdg2SO7Is-Gk.png?width=960&crop=smart&auto=webp&s=da8e0ad5c59939e0d9056a5c02da7a9c0e67fae0", "width": 960, "height": 640}, {"url": "https://external-preview.redd.it/W-WAtZE5DKslM0Jc7ztGVYI5ALKI9kdfdg2SO7Is-Gk.png?width=1080&crop=smart&auto=webp&s=cdb6a73a729564d7890372379f04bc7928ca2e28", "width": 1080, "height": 720}], "variants": {}, "id": "W-WAtZE5DKslM0Jc7ztGVYI5ALKI9kdfdg2SO7Is-Gk"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0ad780a0-1c5e-11ea-978c-0ee7bacb2bff", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#7193ff", "id": "1uleil4", "is_robot_indexable": true, "report_reasons": null, "author": "ModernPython", "discussion_type": null, "num_comments": 13, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uleil4/fastapi_appfrontend_serving_a_frontend_build_from/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uleil4/fastapi_appfrontend_serving_a_frontend_build_from/", "subreddit_subscribers": 1493425, "created_utc": 1782988324.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "# Weekly Thread: Meta Discussions and Free Talk Friday \ud83c\udf99\ufe0f\n\nWelcome to Free Talk Friday on /r/Python! This is the place to discuss the r/Python community (meta discussions), Python news, projects, or anything else Python-related!\n\n## How it Works:\n\n1. **Open Mic**: Share your thoughts, questions, or anything you'd like related to Python or the community.\n2. **Community Pulse**: Discuss what you feel is working well or what could be improved in the /r/python community.\n3. **News & Updates**: Keep up-to-date with the latest in Python and share any news you find interesting.\n\n## Guidelines:\n\n* All topics should be related to Python or the /r/python community.\n* Be respectful and follow Reddit's [Code of Conduct](https://www.redditinc.com/policies/content-policy).\n\n## Example Topics:\n\n1. **New Python Release**: What do you think about the new features in Python 3.11?\n2. **Community Events**: Any Python meetups or webinars coming up?\n3. **Learning Resources**: Found a great Python tutorial? Share it here!\n4. **Job Market**: How has Python impacted your career?\n5. **Hot Takes**: Got a controversial Python opinion? Let's hear it!\n6. **Community Ideas**: Something you'd like to see us do? tell us.\n\nLet's keep the conversation going. Happy discussing! \ud83c\udf1f", "author_fullname": "t2_6l4z3", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Friday Daily Thread: r/Python Meta and Free-Talk Fridays", "link_flair_richtext": [{"a": ":pythonLogo:", "e": "emoji", "u": "https://emoji.redditmedia.com/8yxdpg6xxnr71_t5_2qh0y/pythonLogo"}, {"e": "text", "t": " Daily Thread"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "daily-thread", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ulyrvh", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.86, "author_flair_background_color": null, "subreddit_type": "public", "ups": 5, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": ":pythonLogo: Daily Thread", "can_mod_post": false, "score": 5, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": true, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1783036824.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Weekly Thread: Meta Discussions and Free Talk Friday \ud83c\udf99\ufe0f \n\n
Welcome to Free Talk Friday on /r/Python ! This is the place to discuss the r/Python community (meta discussions), Python news, projects, or anything else Python-related!
\n\n
How it Works: \n\n
\nOpen Mic : Share your thoughts, questions, or anything you'd like related to Python or the community. \nCommunity Pulse : Discuss what you feel is working well or what could be improved in the /r/python community. \nNews & Updates : Keep up-to-date with the latest in Python and share any news you find interesting. \n \n\n
Guidelines: \n\n
\nAll topics should be related to Python or the /r/python community. \nBe respectful and follow Reddit's Code of Conduct . \n \n\n
Example Topics: \n\n
\nNew Python Release : What do you think about the new features in Python 3.11? \nCommunity Events : Any Python meetups or webinars coming up? \nLearning Resources : Found a great Python tutorial? Share it here! \nJob Market : How has Python impacted your career? \nHot Takes : Got a controversial Python opinion? Let's hear it! \nCommunity Ideas : Something you'd like to see us do? tell us. \n \n\n
Let's keep the conversation going. Happy discussing! \ud83c\udf1f
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "6c024934-de3f-11ea-a05a-0ea86b2be9a1", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#00a6a5", "id": "1ulyrvh", "is_robot_indexable": true, "report_reasons": null, "author": "AutoModerator", "discussion_type": null, "num_comments": 2, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1ulyrvh/friday_daily_thread_rpython_meta_and_freetalk/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ulyrvh/friday_daily_thread_rpython_meta_and_freetalk/", "subreddit_subscribers": 1493425, "created_utc": 1783036824.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "Running Celery on AWS ECS can be trickier than it seems if you want to avoid lost tasks and ensure all work is completed. Especially if you're frequently deploying to production and using autoscaling.\n\nThere are two main components for reliable processing:\n- Celery configuration updates\n- Structuring tasks\n\nFor Celery, you should update the following settings:\n- task_acks_late -> True: To treat tasks as successfully processed only after processing. Otherwise, tasks are not retried.\n- task_reject_on_worker_lost -> True: To ensure tasks are retried if workers die for any reason (e.g., warm shutdown + SIGKILL).\n- worker_prefetch_multiplier -> 1: To avoid unnecessarily delayed tasks.\n- broker_connection_retry_on_startup -> True: To make startups more reliable.\n- broker_transport_options -> {\"confirm_publish\": True}: To avoid unsubmitted tasks due to message transport issues.\n- Make sure exponential retries are enabled. This way, you ensure that tasks are retried in the event of an interruption.\n\nFor structuring tasks, use the following two approaches:\n- Batching: Instead of doing all the work at once, you split the work into batches. e.g., Process 1000 users, then submit the next job to process the next 1000 users.\n- Fan out: You can split the work between a \"scheduler\" task and \"execution\" tasks. e.g., One task to list all the users and submit email sending tasks, another task to actually send an email for the selected user\n\nThe same applies to other similar services, such as Heroku and Azure App Containers, which use short grace periods during rolling deployments and downscaling.\n\nYou can read a more elaborate tutorial here: https://jangiacomelli.com/blog/celery-on-aws-ecs/\n", "author_fullname": "t2_5uvfj9zf", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Celery on AWS ECS - prevent lost tasks and ensure the work is always done", "link_flair_richtext": [{"e": "text", "t": "Tutorial"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "tutorial", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uleyez", "quarantine": false, "link_flair_text_color": "dark", "upvote_ratio": 0.82, "author_flair_background_color": null, "subreddit_type": "public", "ups": 31, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Tutorial", "can_mod_post": false, "score": 31, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1782989774.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Running Celery on AWS ECS can be trickier than it seems if you want to avoid lost tasks and ensure all work is completed. Especially if you're frequently deploying to production and using autoscaling.
\n\n
There are two main components for reliable processing:\n- Celery configuration updates\n- Structuring tasks
\n\n
For Celery, you should update the following settings:\n- task_acks_late -> True: To treat tasks as successfully processed only after processing. Otherwise, tasks are not retried.\n- task_reject_on_worker_lost -> True: To ensure tasks are retried if workers die for any reason (e.g., warm shutdown + SIGKILL).\n- worker_prefetch_multiplier -> 1: To avoid unnecessarily delayed tasks.\n- broker_connection_retry_on_startup -> True: To make startups more reliable.\n- broker_transport_options -> {"confirm_publish": True}: To avoid unsubmitted tasks due to message transport issues.\n- Make sure exponential retries are enabled. This way, you ensure that tasks are retried in the event of an interruption.
\n\n
For structuring tasks, use the following two approaches:\n- Batching: Instead of doing all the work at once, you split the work into batches. e.g., Process 1000 users, then submit the next job to process the next 1000 users.\n- Fan out: You can split the work between a "scheduler" task and "execution" tasks. e.g., One task to list all the users and submit email sending tasks, another task to actually send an email for the selected user
\n\n
The same applies to other similar services, such as Heroku and Azure App Containers, which use short grace periods during rolling deployments and downscaling.
\n\n
You can read a more elaborate tutorial here: https://jangiacomelli.com/blog/celery-on-aws-ecs/
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/mw6L6MkeGzNGpfloYiy2lOpj4iblwUcNtBT7-YQogdc.png?auto=webp&s=4476a039806bd30ea3113713247eeb17736e2a04", "width": 1200, "height": 630}, "resolutions": [{"url": "https://external-preview.redd.it/mw6L6MkeGzNGpfloYiy2lOpj4iblwUcNtBT7-YQogdc.png?width=108&crop=smart&auto=webp&s=e004a2cd64b3e00a25e30cf9334f09216b2013ca", "width": 108, "height": 56}, {"url": "https://external-preview.redd.it/mw6L6MkeGzNGpfloYiy2lOpj4iblwUcNtBT7-YQogdc.png?width=216&crop=smart&auto=webp&s=f8b05628d10153dae539aa93e3d9672de283ce16", "width": 216, "height": 113}, {"url": "https://external-preview.redd.it/mw6L6MkeGzNGpfloYiy2lOpj4iblwUcNtBT7-YQogdc.png?width=320&crop=smart&auto=webp&s=008f5cc3e3a7b2997f9efce55cdfd4f4b761911a", "width": 320, "height": 168}, {"url": "https://external-preview.redd.it/mw6L6MkeGzNGpfloYiy2lOpj4iblwUcNtBT7-YQogdc.png?width=640&crop=smart&auto=webp&s=16910f1218cbb38fa5649ceec04feb8624bca35e", "width": 640, "height": 336}, {"url": "https://external-preview.redd.it/mw6L6MkeGzNGpfloYiy2lOpj4iblwUcNtBT7-YQogdc.png?width=960&crop=smart&auto=webp&s=5dac9bf645784e560b4aefd42968c8377f26656f", "width": 960, "height": 504}, {"url": "https://external-preview.redd.it/mw6L6MkeGzNGpfloYiy2lOpj4iblwUcNtBT7-YQogdc.png?width=1080&crop=smart&auto=webp&s=904e0e68111e9dafe766adb0b7d95fc53e94668b", "width": 1080, "height": 567}], "variants": {}, "id": "mw6L6MkeGzNGpfloYiy2lOpj4iblwUcNtBT7-YQogdc"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "7987a74c-04d8-11eb-84ca-0e0ac8b5a78f", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#dadada", "id": "1uleyez", "is_robot_indexable": true, "report_reasons": null, "author": "JanGiacomelli", "discussion_type": null, "num_comments": 13, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uleyez/celery_on_aws_ecs_prevent_lost_tasks_and_ensure/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uleyez/celery_on_aws_ecs_prevent_lost_tasks_and_ensure/", "subreddit_subscribers": 1493425, "created_utc": 1782989774.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "# Weekly Thread: Professional Use, Jobs, and Education \ud83c\udfe2\n\nWelcome to this week's discussion on Python in the professional world! This is your spot to talk about job hunting, career growth, and educational resources in Python. Please note, this thread is **not for recruitment**.\n\n---\n\n## How it Works:\n\n1. **Career Talk**: Discuss using Python in your job, or the job market for Python roles.\n2. **Education Q&A**: Ask or answer questions about Python courses, certifications, and educational resources.\n3. **Workplace Chat**: Share your experiences, challenges, or success stories about using Python professionally.\n\n---\n\n## Guidelines:\n\n- This thread is **not for recruitment**. For job postings, please see r/PythonJobs or the recruitment thread in the sidebar.\n- Keep discussions relevant to Python in the professional and educational context.\n \n---\n\n## Example Topics:\n\n1. **Career Paths**: What kinds of roles are out there for Python developers?\n2. **Certifications**: Are Python certifications worth it?\n3. **Course Recommendations**: Any good advanced Python courses to recommend?\n4. **Workplace Tools**: What Python libraries are indispensable in your professional work?\n5. **Interview Tips**: What types of Python questions are commonly asked in interviews?\n\n---\n\nLet's help each other grow in our careers and education. Happy discussing! \ud83c\udf1f", "author_fullname": "t2_6l4z3", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Thursday Daily Thread: Python Careers, Courses, and Furthering Education!", "link_flair_richtext": [{"a": ":pythonLogo:", "e": "emoji", "u": "https://emoji.redditmedia.com/8yxdpg6xxnr71_t5_2qh0y/pythonLogo"}, {"e": "text", "t": " Daily Thread"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "daily-thread", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ul2dky", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.73, "author_flair_background_color": null, "subreddit_type": "public", "ups": 8, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": ":pythonLogo: Daily Thread", "can_mod_post": false, "score": 8, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": true, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782950428.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Weekly Thread: Professional Use, Jobs, and Education \ud83c\udfe2 \n\n
Welcome to this week's discussion on Python in the professional world! This is your spot to talk about job hunting, career growth, and educational resources in Python. Please note, this thread is not for recruitment .
\n\n
\n\n
How it Works: \n\n
\nCareer Talk : Discuss using Python in your job, or the job market for Python roles. \nEducation Q&A : Ask or answer questions about Python courses, certifications, and educational resources. \nWorkplace Chat : Share your experiences, challenges, or success stories about using Python professionally. \n \n\n
\n\n
Guidelines: \n\n
\nThis thread is not for recruitment . For job postings, please see r/PythonJobs or the recruitment thread in the sidebar. \nKeep discussions relevant to Python in the professional and educational context. \n \n\n
\n\n
Example Topics: \n\n
\nCareer Paths : What kinds of roles are out there for Python developers? \nCertifications : Are Python certifications worth it? \nCourse Recommendations : Any good advanced Python courses to recommend? \nWorkplace Tools : What Python libraries are indispensable in your professional work? \nInterview Tips : What types of Python questions are commonly asked in interviews? \n \n\n
\n\n
Let's help each other grow in our careers and education. Happy discussing! \ud83c\udf1f
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "6c024934-de3f-11ea-a05a-0ea86b2be9a1", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": "moderator", "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#00a6a5", "id": "1ul2dky", "is_robot_indexable": true, "report_reasons": null, "author": "AutoModerator", "discussion_type": null, "num_comments": 2, "send_replies": false, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1ul2dky/thursday_daily_thread_python_careers_courses_and/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ul2dky/thursday_daily_thread_python_careers_courses_and/", "subreddit_subscribers": 1493425, "created_utc": 1782950428.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "A tip that's saved us a lot of boilerplate across our Python stack (Litestar, and our document-extraction tooling): stop decoding JSON into `dict[str, Any]` and casting/`.get()`-ing your way through it. Decode straight into your declared type.\n\n`msgspec` validates and decodes directly into your type at C speed. Quick comparison of the usual options on the same payload:\n\n- `json.loads` / `orjson.loads` -> `dict[str, Any]` (cast and pray; orjson just faster)\n- `pydantic` TypeAdapter(...).validate_json -> your model, validated + rich, but heavier\n- `msgspec.json.decode(raw, type=T)` -> your type, validated, C-fast\n\npydantic does far more and its Rust core is fast; for model-heavy code it's still my default. But on hot paths where you just need decode-into-a-struct, a C decoder going straight to the type is hard to beat.\n\nWith PEP 695 generics the whole (de)serialization layer collapses to one function:\n\n```python\ndef deserialize[T](raw: bytes, t: type[T]) -> T:\n return msgspec.json.decode(raw, type=t, strict=False)\n\ndeserialize(raw, Grant) # -> Grant\ndeserialize(raw, list[Grant]) # -> list[Grant]\n```\n\nWe landed on this while building Litestar (msgspec is a big reason it's fast) and reuse it across everything now. How do you handle hot-path decoding \u2014 msgspec, orjson + manual validation, or full pydantic?", "author_fullname": "t2_9t15mit", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Tip: use msgspec for JSON decoding \u2014 it decodes straight into your type at C speed", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ukh3q5", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.87, "author_flair_background_color": "transparent", "subreddit_type": "public", "ups": 106, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": "67d01c9c-537b-11ee-b0d0-7225f76af176", "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 106, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [{"e": "text", "t": "Pythonista"}], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782899155.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "richtext", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "A tip that's saved us a lot of boilerplate across our Python stack (Litestar, and our document-extraction tooling): stop decoding JSON into dict[str, Any] and casting/.get()-ing your way through it. Decode straight into your declared type.
\n\n
msgspec validates and decodes directly into your type at C speed. Quick comparison of the usual options on the same payload:
\n\n
\njson.loads / orjson.loads -> dict[str, Any] (cast and pray; orjson just faster) \npydantic TypeAdapter(...).validate_json -> your model, validated + rich, but heavier \nmsgspec.json.decode(raw, type=T) -> your type, validated, C-fast \n \n\n
pydantic does far more and its Rust core is fast; for model-heavy code it's still my default. But on hot paths where you just need decode-into-a-struct, a C decoder going straight to the type is hard to beat.
\n\n
With PEP 695 generics the whole (de)serialization layer collapses to one function:
\n\n
```python\ndef deserialize[T](raw: bytes, t: type[T]) -> T:\n return msgspec.json.decode(raw, type=t, strict=False)
\n\n
deserialize(raw, Grant) # -> Grant\ndeserialize(raw, list[Grant]) # -> list[Grant]\n```
\n\n
We landed on this while building Litestar (msgspec is a big reason it's fast) and reuse it across everything now. How do you handle hot-path decoding \u2014 msgspec, orjson + manual validation, or full pydantic?
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": "Pythonista", "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1ukh3q5", "is_robot_indexable": true, "report_reasons": null, "author": "Goldziher", "discussion_type": null, "num_comments": 26, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": "dark", "permalink": "/r/Python/comments/1ukh3q5/tip_use_msgspec_for_json_decoding_it_decodes/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ukh3q5/tip_use_msgspec_for_json_decoding_it_decodes/", "subreddit_subscribers": 1493425, "created_utc": 1782899155.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "I\u2019ve been using the Pythonista IDE by Ole Zorn for over 10 years and I\u2019m just amazed at how consistently good it is. It doesn\u2019t have the latest greatest features but I still use it almost daily. Works with IOS Shortcuts as well. This would be a good one to add to the Wiki.", "author_fullname": "t2_q6jo6", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Pythonista IDE for IOS should be added to the Wiki", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ul4j9h", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.65, "author_flair_background_color": null, "subreddit_type": "public", "ups": 5, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 5, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782956290.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "I\u2019ve been using the Pythonista IDE by Ole Zorn for over 10 years and I\u2019m just amazed at how consistently good it is. It doesn\u2019t have the latest greatest features but I still use it almost daily. Works with IOS Shortcuts as well. This would be a good one to add to the Wiki.
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1ul4j9h", "is_robot_indexable": true, "report_reasons": null, "author": "TutorialDoctor", "discussion_type": null, "num_comments": 8, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1ul4j9h/pythonista_ide_for_ios_should_be_added_to_the_wiki/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ul4j9h/pythonista_ide_for_ios_should_be_added_to_the_wiki/", "subreddit_subscribers": 1493425, "created_utc": 1782956290.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "**The clustering problem with correlated signals**\n\nMy system scores ~40 macro signals (Fed funds rate, yield curve, M2, insider buying, short interest, etc.) and generates a composite \"confluence score\" for a given ticker. The naive approach is to just average the signals. Problem: many signals are correlated \u2014 yield curve and credit spreads move together, insider buying and short interest are often inversely related. Averaging them inflates apparent confidence.\n\nFix I landed on: pairwise Pearson correlation matrix using pandas + numpy on 3 years of weekly signal history. Then `scipy.cluster.hierarchy.linkage` with single-linkage at a 0.6 threshold groups correlated signals into clusters. Each cluster gets one vote, weighted by the cluster member with the best out-of-sample Sharpe ratio on that ticker's 60-day forward returns.\n\n**Streamlit caching gotchas**\n\n`@st.cache_data` is great but has a subtle memory issue: it keeps ALL cached versions until max_entries is hit. For a function that fetches 40 signals with 5 time-period variations, you can end up caching 200+ DataFrames. Added `max_entries=1` to the main signals cache \u2014 memory dropped from ~1.1GB to ~200MB under concurrent load.\n\nAlso: calling `ThreadPoolExecutor` inside a cached function is fine for pure data fetching. But if the cached function spawns threads that themselves call other cached functions, you can hit Streamlit's session state lock. Solution: only parallelize at the outermost uncached layer.\n\n**SEC EDGAR Form 4 XML parsing**\n\nEDGAR serves Form 4 filings as XML, but namespace handling is inconsistent across filings. Some have explicit xmlns declarations, some don't. I strip namespaces with a regex before parsing:\n\n xml_str = re.sub(r'\\s*xmlns[^\"]*\"[^\"]*\"', '', raw_xml)\n tree = ET.fromstring(xml_str)\n\nFor insider cluster detection (flagging when 2+ insiders buy within 21 days), I group by issuer CIK, filter for `transactionCode == 'P'` (open-market purchase), then use a rolling window on sorted transaction dates.\n\n**SQLAlchemy Core schema**\n\nUsing SQLAlchemy Core (not ORM) for the main tables: users, signal_snapshots, watchlist_items, alerts. One thing I'm glad I did: a single DATABASE_URL env var that switches between Postgres (prod) and SQLite (local dev). Same schema DDL works for both \u2014 keeps the local dev loop fast.\n\nHappy to answer questions on any of the above.", "author_fullname": "t2_d021y6fm", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Learned a lot building a macro signal scoring system in Python - sharing architecture decisions", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ul7qiv", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.56, "author_flair_background_color": null, "subreddit_type": "public", "ups": 1, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 1, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782965413.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "The clustering problem with correlated signals
\n\n
My system scores ~40 macro signals (Fed funds rate, yield curve, M2, insider buying, short interest, etc.) and generates a composite "confluence score" for a given ticker. The naive approach is to just average the signals. Problem: many signals are correlated \u2014 yield curve and credit spreads move together, insider buying and short interest are often inversely related. Averaging them inflates apparent confidence.
\n\n
Fix I landed on: pairwise Pearson correlation matrix using pandas + numpy on 3 years of weekly signal history. Then scipy.cluster.hierarchy.linkage with single-linkage at a 0.6 threshold groups correlated signals into clusters. Each cluster gets one vote, weighted by the cluster member with the best out-of-sample Sharpe ratio on that ticker's 60-day forward returns.
\n\n
Streamlit caching gotchas
\n\n
@st.cache_data is great but has a subtle memory issue: it keeps ALL cached versions until max_entries is hit. For a function that fetches 40 signals with 5 time-period variations, you can end up caching 200+ DataFrames. Added max_entries=1 to the main signals cache \u2014 memory dropped from ~1.1GB to ~200MB under concurrent load.
\n\n
Also: calling ThreadPoolExecutor inside a cached function is fine for pure data fetching. But if the cached function spawns threads that themselves call other cached functions, you can hit Streamlit's session state lock. Solution: only parallelize at the outermost uncached layer.
\n\n
SEC EDGAR Form 4 XML parsing
\n\n
EDGAR serves Form 4 filings as XML, but namespace handling is inconsistent across filings. Some have explicit xmlns declarations, some don't. I strip namespaces with a regex before parsing:
\n\n
xml_str = re.sub(r'\\s*xmlns[^"]*"[^"]*"', '', raw_xml)\ntree = ET.fromstring(xml_str)\n\n\n
For insider cluster detection (flagging when 2+ insiders buy within 21 days), I group by issuer CIK, filter for transactionCode == 'P' (open-market purchase), then use a rolling window on sorted transaction dates.
\n\n
SQLAlchemy Core schema
\n\n
Using SQLAlchemy Core (not ORM) for the main tables: users, signal_snapshots, watchlist_items, alerts. One thing I'm glad I did: a single DATABASE_URL env var that switches between Postgres (prod) and SQLite (local dev). Same schema DDL works for both \u2014 keeps the local dev loop fast.
\n\n
Happy to answer questions on any of the above.
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": true, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1ul7qiv", "is_robot_indexable": true, "report_reasons": null, "author": "Historical_Ad9654", "discussion_type": null, "num_comments": 2, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1ul7qiv/learned_a_lot_building_a_macro_signal_scoring/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ul7qiv/learned_a_lot_building_a_macro_signal_scoring/", "subreddit_subscribers": 1493425, "created_utc": 1782965413.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "I saw one yesterday during an interview and it really confused me at first since the feature has been deprecated for so long.\n\n \nAre there still code bases out there running Python 2? I used Python 3.8 at my last job and that made me feel like a dinosaur. ", "author_fullname": "t2_1q305nm7", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "When's the last time you saw Python 2 Super() syntax?", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uk3ym6", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.87, "author_flair_background_color": null, "subreddit_type": "public", "ups": 103, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 103, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782859080.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "I saw one yesterday during an interview and it really confused me at first since the feature has been deprecated for so long.
\n\n
Are there still code bases out there running Python 2? I used Python 3.8 at my last job and that made me feel like a dinosaur.
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1uk3ym6", "is_robot_indexable": true, "report_reasons": null, "author": "GongtingLover", "discussion_type": null, "num_comments": 86, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uk3ym6/whens_the_last_time_you_saw_python_2_super_syntax/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uk3ym6/whens_the_last_time_you_saw_python_2_super_syntax/", "subreddit_subscribers": 1493425, "created_utc": 1782859080.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "The\u00a0Triple Product Property *(*TPP) algorithm is an obscure matmul algorithm that uses group theory (instead of linear algebra) to find matrix products.\n\nOne may summarize it as a fast fourier transform for multiplying matrices. The algorithm was published by Microsoft and Caltech researchers in 2003 but the original paper's math-heavy. I coded the paper in Python to make matrix multiplication research accessible to everyone.\n\nGitHub: [https://github.com/MurageKibicho/The-Annotated-Triple-Product-Property-Matrix-Multiplication-Algorithm/tree/main](https://github.com/MurageKibicho/The-Annotated-Triple-Product-Property-Matrix-Multiplication-Algorithm/tree/main)\n\nWritten Guide: [https://leetarxiv.substack.com/p/triple-product-property-matrix-multiplication](https://leetarxiv.substack.com/p/triple-product-property-matrix-multiplication)\n\n", "author_fullname": "t2_3xvmpjwh9", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Annotated Triple Product Property Matrix Multiplication Algorithm In Python", "link_flair_richtext": [{"e": "text", "t": "Tutorial"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "tutorial", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uknaq3", "quarantine": false, "link_flair_text_color": "dark", "upvote_ratio": 0.67, "author_flair_background_color": null, "subreddit_type": "public", "ups": 1, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Tutorial", "can_mod_post": false, "score": 1, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782916193.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": true, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "7987a74c-04d8-11eb-84ca-0e0ac8b5a78f", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#dadada", "id": "1uknaq3", "is_robot_indexable": true, "report_reasons": null, "author": "DataBaeBee", "discussion_type": null, "num_comments": 0, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uknaq3/annotated_triple_product_property_matrix/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uknaq3/annotated_triple_product_property_matrix/", "subreddit_subscribers": 1493425, "created_utc": 1782916193.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "I've been experimenting with AI agents and autocomplete platforms for a greenfield FastAPI project. In the first few weeks, it felt incredibly fast. But now that we've scaled to multiple routers, complex Pydantic schemas, and SQLAlchemy models, the structural debt is piling up.\n\nThe AI writes code that functions, but it constantly violates our architecture. It'll put complex business logic inside a route handler instead of the service layer, or it'll mess up async database sessions across modules. I find myself spending more time refactoring the structure of what it built than it would have taken to write the logic myself.\n\nIs anyone else hitting this scaling wall where AI utility drops off as codebase complexity grows? How are you keeping your system architecture clean?", "author_fullname": "t2_4xeh5jtc", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Mitigating \"architectural drift\" in large Python backend codebases using AI tools", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ujy1a2", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.73, "author_flair_background_color": null, "subreddit_type": "public", "ups": 32, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 32, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782845636.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "I've been experimenting with AI agents and autocomplete platforms for a greenfield FastAPI project. In the first few weeks, it felt incredibly fast. But now that we've scaled to multiple routers, complex Pydantic schemas, and SQLAlchemy models, the structural debt is piling up.
\n\n
The AI writes code that functions, but it constantly violates our architecture. It'll put complex business logic inside a route handler instead of the service layer, or it'll mess up async database sessions across modules. I find myself spending more time refactoring the structure of what it built than it would have taken to write the logic myself.
\n\n
Is anyone else hitting this scaling wall where AI utility drops off as codebase complexity grows? How are you keeping your system architecture clean?
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1ujy1a2", "is_robot_indexable": true, "report_reasons": null, "author": "CrazyGeek7", "discussion_type": null, "num_comments": 42, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1ujy1a2/mitigating_architectural_drift_in_large_python/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ujy1a2/mitigating_architectural_drift_in_large_python/", "subreddit_subscribers": 1493425, "created_utc": 1782845636.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "Please read this and contribute your opinions so that this becomes basic foundation for spring devs who adapt to python .\n\nhttps://bunny-learner.github.io/Python-Handbook-for-Spring-Devs/", "author_fullname": "t2_1gpwvz9l5s", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Python handbook for spring devs", "link_flair_richtext": [{"e": "text", "t": "Resource"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "resource", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ukn0wi", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.25, "author_flair_background_color": null, "subreddit_type": "public", "ups": 0, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Resource", "can_mod_post": false, "score": 0, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782915570.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": true, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "f9716fb2-4113-11ea-a3f1-0ef51f60f757", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#ddbd37", "id": "1ukn0wi", "is_robot_indexable": true, "report_reasons": null, "author": "External-Wait-2583", "discussion_type": null, "num_comments": 2, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1ukn0wi/python_handbook_for_spring_devs/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ukn0wi/python_handbook_for_spring_devs/", "subreddit_subscribers": 1493425, "created_utc": 1782915570.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "I just spent three hours trying to get a basic python cron script to send out a weekly web scraping summary. used to just use `smtplib` and a random gmail app password but google basically killed that workflow \n \nTried installing the official python sdk for one of the big email providers and it pulled in like 6 different async dependencies just to send a plain text string. It is genuinely insane how bloated the modern python ecosystem has gotten for the most basic tasks \n \nI ended up just writing a simple `requests.post()` webhook over to yaplet to handle the actual subscriber list and formatting because I absolutely refuse to fight with another bloated `__init__.py` or dns auth protocol this month \n \nsometimes it really feels like we spend 10% of our time writing actual python logic and 90% fighting with enterprise api wrappers tbh", "author_fullname": "t2_81nfn6e5d", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Why is sending an automated email with python still a nightmare in 2026", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ukljb6", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.25, "author_flair_background_color": null, "subreddit_type": "public", "ups": 0, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 0, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782912026.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "I just spent three hours trying to get a basic python cron script to send out a weekly web scraping summary. used to just use smtplib and a random gmail app password but google basically killed that workflow
\n\n
Tried installing the official python sdk for one of the big email providers and it pulled in like 6 different async dependencies just to send a plain text string. It is genuinely insane how bloated the modern python ecosystem has gotten for the most basic tasks
\n\n
I ended up just writing a simple requests.post() webhook over to yaplet to handle the actual subscriber list and formatting because I absolutely refuse to fight with another bloated __init__.py or dns auth protocol this month
\n\n
sometimes it really feels like we spend 10% of our time writing actual python logic and 90% fighting with enterprise api wrappers tbh
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": true, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1ukljb6", "is_robot_indexable": true, "report_reasons": null, "author": "Crystallover1991", "discussion_type": null, "num_comments": 20, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1ukljb6/why_is_sending_an_automated_email_with_python/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ukljb6/why_is_sending_an_automated_email_with_python/", "subreddit_subscribers": 1493425, "created_utc": 1782912026.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "This is the first part of a multi-part series exploring why `async/await` might not be the best concurrency pattern for most use cases, and what alternative models you should consider instead. Using Python for our practical examples, this opening post digs into the roots of `async/await`, guiding you through building a custom event loop from scratch using generators.\n\n[https://theblog.info/posts/asyncawait-is-a-plague-part-1-roots](https://theblog.info/posts/asyncawait-is-a-plague-part-1-roots)\n\n\n**Note:** This is Part 1 of a multi-part series. Instead of diving straight into why `async/await` can be problematic, this post explores the original motivations behind the pattern. Understanding how it works under the hood will provide the essential context for the issues we'll discuss in upcoming parts.", "author_fullname": "t2_rkfihkkzk", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Async/Await is a Plague: Part 1 Roots", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uisy46", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.7, "author_flair_background_color": null, "subreddit_type": "public", "ups": 73, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 73, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": 1782753438.0, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1782740712.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "This is the first part of a multi-part series exploring why async/await might not be the best concurrency pattern for most use cases, and what alternative models you should consider instead. Using Python for our practical examples, this opening post digs into the roots of async/await, guiding you through building a custom event loop from scratch using generators.
\n\n
https://theblog.info/posts/asyncawait-is-a-plague-part-1-roots
\n\n
Note: This is Part 1 of a multi-part series. Instead of diving straight into why async/await can be problematic, this post explores the original motivations behind the pattern. Understanding how it works under the hood will provide the essential context for the issues we'll discuss in upcoming parts.
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/o5iToZglarhA_St0cCPapqc_aoL8yr3wvMq-nTIXbBY.jpeg?auto=webp&s=8c085a9706bd5c46bf2669a92aec2fe9a968b931", "width": 640, "height": 640}, "resolutions": [{"url": "https://external-preview.redd.it/o5iToZglarhA_St0cCPapqc_aoL8yr3wvMq-nTIXbBY.jpeg?width=108&crop=smart&auto=webp&s=2ee3e5583e9679af89b4127ff8ef801ad96d7c21", "width": 108, "height": 108}, {"url": "https://external-preview.redd.it/o5iToZglarhA_St0cCPapqc_aoL8yr3wvMq-nTIXbBY.jpeg?width=216&crop=smart&auto=webp&s=6855cb906f7b5e61cd39e971a6813b92089c9ddc", "width": 216, "height": 216}, {"url": "https://external-preview.redd.it/o5iToZglarhA_St0cCPapqc_aoL8yr3wvMq-nTIXbBY.jpeg?width=320&crop=smart&auto=webp&s=6cc8885184c29b6a2bf5b3cda04f8e48f141681b", "width": 320, "height": 320}, {"url": "https://external-preview.redd.it/o5iToZglarhA_St0cCPapqc_aoL8yr3wvMq-nTIXbBY.jpeg?width=640&crop=smart&auto=webp&s=ed1a0969eb982fc4f33d3b725973b3d4a9165394", "width": 640, "height": 640}], "variants": {}, "id": "o5iToZglarhA_St0cCPapqc_aoL8yr3wvMq-nTIXbBY"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1uisy46", "is_robot_indexable": true, "report_reasons": null, "author": "EntryNo8040", "discussion_type": null, "num_comments": 71, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uisy46/asyncawait_is_a_plague_part_1_roots/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uisy46/asyncawait_is_a_plague_part_1_roots/", "subreddit_subscribers": 1493425, "created_utc": 1782740712.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "# Weekly Wednesday Thread: Advanced Questions \ud83d\udc0d\n\nDive deep into Python with our Advanced Questions thread! This space is reserved for questions about more advanced Python topics, frameworks, and best practices.\n\n## How it Works:\n\n1. **Ask Away**: Post your advanced Python questions here.\n2. **Expert Insights**: Get answers from experienced developers.\n3. **Resource Pool**: Share or discover tutorials, articles, and tips.\n\n## Guidelines:\n\n* This thread is for **advanced questions only**. Beginner questions are welcome in our [Daily Beginner Thread](#daily-beginner-thread-link) every Thursday.\n* Questions that are not advanced may be removed and redirected to the appropriate thread.\n\n## Recommended Resources:\n\n* If you don't receive a response, consider exploring r/LearnPython or join the [Python Discord Server](https://discord.gg/python) for quicker assistance.\n\n## Example Questions:\n\n1. **How can you implement a custom memory allocator in Python?**\n2. **What are the best practices for optimizing Cython code for heavy numerical computations?**\n3. **How do you set up a multi-threaded architecture using Python's Global Interpreter Lock (GIL)?**\n4. **Can you explain the intricacies of metaclasses and how they influence object-oriented design in Python?**\n5. **How would you go about implementing a distributed task queue using Celery and RabbitMQ?**\n6. **What are some advanced use-cases for Python's decorators?**\n7. **How can you achieve real-time data streaming in Python with WebSockets?**\n8. **What are the performance implications of using native Python data structures vs NumPy arrays for large-scale data?**\n9. **Best practices for securing a Flask (or similar) REST API with OAuth 2.0?**\n10. **What are the best practices for using Python in a microservices architecture? (..and more generally, should I even use microservices?)**\n\nLet's deepen our Python knowledge together. Happy coding! \ud83c\udf1f", "author_fullname": "t2_6l4z3", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Tuesday Daily Thread: Advanced questions", "link_flair_richtext": [{"a": ":pythonLogo:", "e": "emoji", "u": "https://emoji.redditmedia.com/8yxdpg6xxnr71_t5_2qh0y/pythonLogo"}, {"e": "text", "t": " Daily Thread"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "daily-thread", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uj99ws", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.67, "author_flair_background_color": null, "subreddit_type": "public", "ups": 3, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": ":pythonLogo: Daily Thread", "can_mod_post": false, "score": 3, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": true, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1782777606.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Weekly Wednesday Thread: Advanced Questions \ud83d\udc0d \n\n
Dive deep into Python with our Advanced Questions thread! This space is reserved for questions about more advanced Python topics, frameworks, and best practices.
\n\n
How it Works: \n\n
\nAsk Away : Post your advanced Python questions here. \nExpert Insights : Get answers from experienced developers. \nResource Pool : Share or discover tutorials, articles, and tips. \n \n\n
Guidelines: \n\n
\nThis thread is for advanced questions only . Beginner questions are welcome in our Daily Beginner Thread every Thursday. \nQuestions that are not advanced may be removed and redirected to the appropriate thread. \n \n\n
Recommended Resources: \n\n
\n\n
Example Questions: \n\n
\nHow can you implement a custom memory allocator in Python? \nWhat are the best practices for optimizing Cython code for heavy numerical computations? \nHow do you set up a multi-threaded architecture using Python's Global Interpreter Lock (GIL)? \nCan you explain the intricacies of metaclasses and how they influence object-oriented design in Python? \nHow would you go about implementing a distributed task queue using Celery and RabbitMQ? \nWhat are some advanced use-cases for Python's decorators? \nHow can you achieve real-time data streaming in Python with WebSockets? \nWhat are the performance implications of using native Python data structures vs NumPy arrays for large-scale data? \nBest practices for securing a Flask (or similar) REST API with OAuth 2.0? \nWhat are the best practices for using Python in a microservices architecture? (..and more generally, should I even use microservices?) \n \n\n
Let's deepen our Python knowledge together. Happy coding! \ud83c\udf1f
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/Qr0qqr0jX1TjZaLE6_zaVcWJHZQGJPofEJEedmb9MtI.jpeg?auto=webp&s=f0e96c458bf454a41999b79e0f2b4170f1e1c85f", "width": 512, "height": 288}, "resolutions": [{"url": "https://external-preview.redd.it/Qr0qqr0jX1TjZaLE6_zaVcWJHZQGJPofEJEedmb9MtI.jpeg?width=108&crop=smart&auto=webp&s=7726f1bd96bb293ee00f58330b9db8277d3a4e97", "width": 108, "height": 60}, {"url": "https://external-preview.redd.it/Qr0qqr0jX1TjZaLE6_zaVcWJHZQGJPofEJEedmb9MtI.jpeg?width=216&crop=smart&auto=webp&s=7592cc25c340779fdbd514118a98258c5def8dce", "width": 216, "height": 121}, {"url": "https://external-preview.redd.it/Qr0qqr0jX1TjZaLE6_zaVcWJHZQGJPofEJEedmb9MtI.jpeg?width=320&crop=smart&auto=webp&s=6dca793160bdafbbdac9126b927e2b64871738a5", "width": 320, "height": 180}], "variants": {}, "id": "Qr0qqr0jX1TjZaLE6_zaVcWJHZQGJPofEJEedmb9MtI"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "6c024934-de3f-11ea-a05a-0ea86b2be9a1", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": "moderator", "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#00a6a5", "id": "1uj99ws", "is_robot_indexable": true, "report_reasons": null, "author": "AutoModerator", "discussion_type": null, "num_comments": 4, "send_replies": false, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uj99ws/tuesday_daily_thread_advanced_questions/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uj99ws/tuesday_daily_thread_advanced_questions/", "subreddit_subscribers": 1493425, "created_utc": 1782777606.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "We compared the 3 pip-installable mesh boolean libraries on the task of pairwise mesh booleans at industry scale.\n\nFull write up: [https://polydera.com/algorithms/python-mesh-boolean-libraries-2026](https://polydera.com/algorithms/python-mesh-boolean-libraries-2026)\n\n#### Libraries tested\n\n- **[MeshLib](https://meshlib.io/) 3.1** \u2014 `pip install meshlib`. Simulation of Simplicity for degeneracy handling.\n- **[Manifold](https://github.com/elalish/manifold) 3.5** \u2014 `pip install manifold3d`. Deterministic floating point with symbolic perturbation.\n- **[trueform](https://polydera.com/trueform) 0.9.8** \u2014 `pip install trueform`. Topologically-exact arrangements via a bounded integer kernel. Ships native, python and WebAssembly.\n\nAll three are native cores (C++/Rust) with Python bindings.\n\n#### Protocol\n\nEach library is timed from input arrays (vertices, triangles) to output arrays of the same shape. Native-object construction \u2014 acceleration structures, trees, topology \u2014 plus the boolean, all in the timer. Only file I/O is outside. Best of 5, fresh objects every run; nothing amortised across calls.\n\n**Result agreement.** On every pair the three produced the same solid \u2014 signed volumes agree within floating-point tolerance. trueform and Manifold returned a closed, manifold mesh on all 1000 pairs; MeshLib on 999. The comparison is wall-clock only.\n\n**Corpus.** Random sets of solid, manifold, non-self-intersecting Thingi10K meshes, 200K to 1.5M polygons per operand. Each operand is normalised to unit extent, randomly rotated, and translated so the bounding boxes overlap; each pair takes the union. Thingi10K IDs and per-operand counts for every case are published: [pairwise corpus](https://github.com/polydera/trueform/blob/main/research/uncertainty-aware-mesh-csg/data/pairwise-corpus-ids.json).\n\n**Environment.** Apple M4 Max (arm64), macOS, CPython 3.13. Installed from PyPI: trueform 0.9.8, meshlib 3.1.0.75, manifold3d 3.5.1 \u2014 default builds, default thread count. On Apple Silicon the wheel's compiled architecture matters; all three ship native arm64 builds.\n\n#### Results\n\n**Of the libraries you can `pip install`, trueform was the fastest mesh boolean in Python** \u2014 fastest on every one of the 1000 pairwise pairs.\n\n**Pairwise** \u2014 one boolean per pair across the 1000-pair corpus.\n\n| library | median (ms) | geomean \u00d7 vs trueform | valid / 1000 |\n|---|---:|---:|---:|\n| trueform 0.9.8 | 18.0 | 1.0\u00d7 | 1000 |\n| MeshLib 3.1 | 87.6 | 4.9\u00d7 | 999 |\n| Manifold 3.5 | 120.3 | 6.9\u00d7 | 1000 |\n\n---\n\n*Disclosure: I'm one of the authors of trueform.*", "author_fullname": "t2_81ze3aps", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Comparison and Benchmarks of Python Mesh Boolean Libraries at Industry Scale", "link_flair_richtext": [{"e": "text", "t": "Resource"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "resource", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uins5r", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.79, "author_flair_background_color": null, "subreddit_type": "public", "ups": 8, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Resource", "can_mod_post": false, "score": 8, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": 1782753231.0, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1782725887.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "We compared the 3 pip-installable mesh boolean libraries on the task of pairwise mesh booleans at industry scale.
\n\n
Full write up: https://polydera.com/algorithms/python-mesh-boolean-libraries-2026
\n\n
Libraries tested \n\n
\nMeshLib 3.1 \u2014 pip install meshlib. Simulation of Simplicity for degeneracy handling. \nManifold 3.5 \u2014 pip install manifold3d. Deterministic floating point with symbolic perturbation. \ntrueform 0.9.8 \u2014 pip install trueform. Topologically-exact arrangements via a bounded integer kernel. Ships native, python and WebAssembly. \n \n\n
All three are native cores (C++/Rust) with Python bindings.
\n\n
Protocol \n\n
Each library is timed from input arrays (vertices, triangles) to output arrays of the same shape. Native-object construction \u2014 acceleration structures, trees, topology \u2014 plus the boolean, all in the timer. Only file I/O is outside. Best of 5, fresh objects every run; nothing amortised across calls.
\n\n
Result agreement. On every pair the three produced the same solid \u2014 signed volumes agree within floating-point tolerance. trueform and Manifold returned a closed, manifold mesh on all 1000 pairs; MeshLib on 999. The comparison is wall-clock only.
\n\n
Corpus. Random sets of solid, manifold, non-self-intersecting Thingi10K meshes, 200K to 1.5M polygons per operand. Each operand is normalised to unit extent, randomly rotated, and translated so the bounding boxes overlap; each pair takes the union. Thingi10K IDs and per-operand counts for every case are published: pairwise corpus .
\n\n
Environment. Apple M4 Max (arm64), macOS, CPython 3.13. Installed from PyPI: trueform 0.9.8, meshlib 3.1.0.75, manifold3d 3.5.1 \u2014 default builds, default thread count. On Apple Silicon the wheel's compiled architecture matters; all three ship native arm64 builds.
\n\n
Results \n\n
Of the libraries you can pip install, trueform was the fastest mesh boolean in Python \u2014 fastest on every one of the 1000 pairwise pairs.
\n\n
Pairwise \u2014 one boolean per pair across the 1000-pair corpus.
\n\n
\n\nlibrary \nmedian (ms) \ngeomean \u00d7 vs trueform \nvalid / 1000 \n \n \n\ntrueform 0.9.8 \n18.0 \n1.0\u00d7 \n1000 \n \n\nMeshLib 3.1 \n87.6 \n4.9\u00d7 \n999 \n \n\nManifold 3.5 \n120.3 \n6.9\u00d7 \n1000 \n \n
\n\n
\n\n
Disclosure: I'm one of the authors of trueform.
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/uP9ziF1Ybs-odwNAz2IxyEjZSvHb6KCbsUD3bZsW7Rk.png?auto=webp&s=9872ff4503eed4216dbc3d09833ceb2ce9893e9d", "width": 1200, "height": 600}, "resolutions": [{"url": "https://external-preview.redd.it/uP9ziF1Ybs-odwNAz2IxyEjZSvHb6KCbsUD3bZsW7Rk.png?width=108&crop=smart&auto=webp&s=48a6c26427e9bcc0b3e033694a4ad7864ae8aec6", "width": 108, "height": 54}, {"url": "https://external-preview.redd.it/uP9ziF1Ybs-odwNAz2IxyEjZSvHb6KCbsUD3bZsW7Rk.png?width=216&crop=smart&auto=webp&s=3448012d2ca17920431ff0b9af75287cb7ddb127", "width": 216, "height": 108}, {"url": "https://external-preview.redd.it/uP9ziF1Ybs-odwNAz2IxyEjZSvHb6KCbsUD3bZsW7Rk.png?width=320&crop=smart&auto=webp&s=e44c7768721ffdd42c421ee7ded2390eeee2dcff", "width": 320, "height": 160}, {"url": "https://external-preview.redd.it/uP9ziF1Ybs-odwNAz2IxyEjZSvHb6KCbsUD3bZsW7Rk.png?width=640&crop=smart&auto=webp&s=bd31638b30f1d7b12f29c5eab21fed8a1aa66bb6", "width": 640, "height": 320}, {"url": "https://external-preview.redd.it/uP9ziF1Ybs-odwNAz2IxyEjZSvHb6KCbsUD3bZsW7Rk.png?width=960&crop=smart&auto=webp&s=ec326eb5a02b7424897a25e5ef64b39c6b20123b", "width": 960, "height": 480}, {"url": "https://external-preview.redd.it/uP9ziF1Ybs-odwNAz2IxyEjZSvHb6KCbsUD3bZsW7Rk.png?width=1080&crop=smart&auto=webp&s=bd5479c4f871219c0a41d93d1ba87ae6d8c6e9c2", "width": 1080, "height": 540}], "variants": {}, "id": "uP9ziF1Ybs-odwNAz2IxyEjZSvHb6KCbsUD3bZsW7Rk"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "f9716fb2-4113-11ea-a3f1-0ef51f60f757", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#ddbd37", "id": "1uins5r", "is_robot_indexable": true, "report_reasons": null, "author": "Separate-Summer-6027", "discussion_type": null, "num_comments": 4, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uins5r/comparison_and_benchmarks_of_python_mesh_boolean/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uins5r/comparison_and_benchmarks_of_python_mesh_boolean/", "subreddit_subscribers": 1493425, "created_utc": 1782725887.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "# Weekly Thread: Project Ideas \ud83d\udca1\n\nWelcome to our weekly Project Ideas thread! Whether you're a newbie looking for a first project or an expert seeking a new challenge, this is the place for you.\n\n## How it Works:\n\n1. **Suggest a Project**: Comment your project idea\u2014be it beginner-friendly or advanced.\n2. **Build & Share**: If you complete a project, reply to the original comment, share your experience, and attach your source code.\n3. **Explore**: Looking for ideas? Check out Al Sweigart's [\"The Big Book of Small Python Projects\"](https://www.amazon.com/Big-Book-Small-Python-Programming/dp/1718501242) for inspiration.\n\n## Guidelines:\n\n* Clearly state the difficulty level.\n* Provide a brief description and, if possible, outline the tech stack.\n* Feel free to link to tutorials or resources that might help.\n\n# Example Submissions:\n\n## Project Idea: Chatbot\n\n**Difficulty**: Intermediate\n\n**Tech Stack**: Python, NLP, Flask/FastAPI/Litestar \n\n**Description**: Create a chatbot that can answer FAQs for a website.\n\n**Resources**: [Building a Chatbot with Python](https://www.youtube.com/watch?v=a37BL0stIuM)\n\n# Project Idea: Weather Dashboard\n\n**Difficulty**: Beginner\n\n**Tech Stack**: HTML, CSS, JavaScript, API\n\n**Description**: Build a dashboard that displays real-time weather information using a weather API.\n\n**Resources**: [Weather API Tutorial](https://www.youtube.com/watch?v=9P5MY_2i7K8)\n\n## Project Idea: File Organizer\n\n**Difficulty**: Beginner\n\n**Tech Stack**: Python, File I/O\n\n**Description**: Create a script that organizes files in a directory into sub-folders based on file type.\n\n**Resources**: [Automate the Boring Stuff: Organizing Files](https://automatetheboringstuff.com/2e/chapter9/)\n\nLet's help each other grow. Happy coding! \ud83c\udf1f", "author_fullname": "t2_6l4z3", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Monday Daily Thread: Project ideas!", "link_flair_richtext": [{"a": ":pythonLogo:", "e": "emoji", "u": "https://emoji.redditmedia.com/8yxdpg6xxnr71_t5_2qh0y/pythonLogo"}, {"e": "text", "t": " Daily Thread"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "daily-thread", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uictw2", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.67, "author_flair_background_color": null, "subreddit_type": "public", "ups": 4, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": ":pythonLogo: Daily Thread", "can_mod_post": false, "score": 4, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": true, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1782691206.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Weekly Thread: Project Ideas \ud83d\udca1 \n\n
Welcome to our weekly Project Ideas thread! Whether you're a newbie looking for a first project or an expert seeking a new challenge, this is the place for you.
\n\n
How it Works: \n\n
\nSuggest a Project : Comment your project idea\u2014be it beginner-friendly or advanced. \nBuild & Share : If you complete a project, reply to the original comment, share your experience, and attach your source code. \nExplore : Looking for ideas? Check out Al Sweigart's "The Big Book of Small Python Projects" for inspiration. \n \n\n
Guidelines: \n\n
\nClearly state the difficulty level. \nProvide a brief description and, if possible, outline the tech stack. \nFeel free to link to tutorials or resources that might help. \n \n\n
Example Submissions: \n\n
Project Idea: Chatbot \n\n
Difficulty : Intermediate
\n\n
Tech Stack : Python, NLP, Flask/FastAPI/Litestar
\n\n
Description : Create a chatbot that can answer FAQs for a website.
\n\n
Resources : Building a Chatbot with Python
\n\n
Project Idea: Weather Dashboard \n\n
Difficulty : Beginner
\n\n
Tech Stack : HTML, CSS, JavaScript, API
\n\n
Description : Build a dashboard that displays real-time weather information using a weather API.
\n\n
Resources : Weather API Tutorial
\n\n
Project Idea: File Organizer \n\n
Difficulty : Beginner
\n\n
Tech Stack : Python, File I/O
\n\n
Description : Create a script that organizes files in a directory into sub-folders based on file type.
\n\n
Resources : Automate the Boring Stuff: Organizing Files
\n\n
Let's help each other grow. Happy coding! \ud83c\udf1f
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?auto=webp&s=8cdb17f0919f23f3fc3c0bd9dac21cd40118adda", "width": 1910, "height": 1000}, "resolutions": [{"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=108&crop=smart&auto=webp&s=c7ef9713fb4fbf51d0d7da30fb558f95324a395b", "width": 108, "height": 56}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=216&crop=smart&auto=webp&s=70f4ef0366eafa569960666b4537977954dc4da4", "width": 216, "height": 113}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=320&crop=smart&auto=webp&s=e88e6f574ea2b6abf3644be5140a1ed8ad6d613c", "width": 320, "height": 167}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=640&crop=smart&auto=webp&s=290ace7209dd3df0a237ec970a6a8b1662d523e1", "width": 640, "height": 335}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=960&crop=smart&auto=webp&s=421952297faebb04d1038184216c053ab1f0bb56", "width": 960, "height": 502}, {"url": "https://external-preview.redd.it/wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI.png?width=1080&crop=smart&auto=webp&s=2e3704dd3e397c6dbebe004c6cce33e8cd82d316", "width": 1080, "height": 565}], "variants": {}, "id": "wyRlnnC4nIHWRfWMUIBnHvHMsP98N9mROJtKXbnwKWI"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "6c024934-de3f-11ea-a05a-0ea86b2be9a1", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": "moderator", "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#00a6a5", "id": "1uictw2", "is_robot_indexable": true, "report_reasons": null, "author": "AutoModerator", "discussion_type": null, "num_comments": 0, "send_replies": false, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uictw2/monday_daily_thread_project_ideas/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uictw2/monday_daily_thread_project_ideas/", "subreddit_subscribers": 1493425, "created_utc": 1782691206.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "Hey r/learnpython \ud83d\udc4b\n\nBuilt PyRun (pyrun.in) \u2014 a browser-based Python learning platform \n\npowered by Pyodide. Write and run Python directly in your browser, \n\nzero setup needed.\n\nWhat's included:\n\n\u2022 Interactive Python editor (Monaco-based)\n\n\u2022 Structured lessons from beginner to advanced\n\n\u2022 Instant output \u2014 no backend, runs locally in your browser\n\n\u2022 Free to use\n\nWould love feedback from this community \u2014 what topics or features \n\nwould make this more useful for you?\n\nLink: https://pyrun.in", "author_fullname": "t2_2hinuthhdk", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "I built a free in-browser Python learning platform \u2013 no installs, just open and code", "link_flair_richtext": [{"e": "text", "t": "Tutorial"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "tutorial", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uiw602", "quarantine": false, "link_flair_text_color": "dark", "upvote_ratio": 0.25, "author_flair_background_color": null, "subreddit_type": "public", "ups": 0, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Tutorial", "can_mod_post": false, "score": 0, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1782747928.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Hey r/learnpython \ud83d\udc4b
\n\n
Built PyRun (pyrun.in) \u2014 a browser-based Python learning platform
\n\n
powered by Pyodide. Write and run Python directly in your browser,
\n\n
zero setup needed.
\n\n
What's included:
\n\n
\u2022 Interactive Python editor (Monaco-based)
\n\n
\u2022 Structured lessons from beginner to advanced
\n\n
\u2022 Instant output \u2014 no backend, runs locally in your browser
\n\n
\u2022 Free to use
\n\n
Would love feedback from this community \u2014 what topics or features
\n\n
would make this more useful for you?
\n\n
Link: https://pyrun.in
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": true, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/Zbc0eUv1jXytp6GEqwGvRsh-PTjb-xLlLDl976BYG_g.png?auto=webp&s=30a4651979e078369eee9b8f8707a356f8a1cc11", "width": 1200, "height": 630}, "resolutions": [{"url": "https://external-preview.redd.it/Zbc0eUv1jXytp6GEqwGvRsh-PTjb-xLlLDl976BYG_g.png?width=108&crop=smart&auto=webp&s=963a21f143c1243528c655e9506c1d7c98703ade", "width": 108, "height": 56}, {"url": "https://external-preview.redd.it/Zbc0eUv1jXytp6GEqwGvRsh-PTjb-xLlLDl976BYG_g.png?width=216&crop=smart&auto=webp&s=fb89b2dda88c5cf5963a2e6ae03251a188ae0826", "width": 216, "height": 113}, {"url": "https://external-preview.redd.it/Zbc0eUv1jXytp6GEqwGvRsh-PTjb-xLlLDl976BYG_g.png?width=320&crop=smart&auto=webp&s=e38c05daf7fe3afd90e786b07c97f0bff68f0f67", "width": 320, "height": 168}, {"url": "https://external-preview.redd.it/Zbc0eUv1jXytp6GEqwGvRsh-PTjb-xLlLDl976BYG_g.png?width=640&crop=smart&auto=webp&s=29b6d153f034b3b0117e66c68b8ae6a92f4ae98a", "width": 640, "height": 336}, {"url": "https://external-preview.redd.it/Zbc0eUv1jXytp6GEqwGvRsh-PTjb-xLlLDl976BYG_g.png?width=960&crop=smart&auto=webp&s=279a2855c24fae439dd2cc3a0d9b7011039fed27", "width": 960, "height": 504}, {"url": "https://external-preview.redd.it/Zbc0eUv1jXytp6GEqwGvRsh-PTjb-xLlLDl976BYG_g.png?width=1080&crop=smart&auto=webp&s=09560fe60b9e09b9e38403956c12f60d4437efc5", "width": 1080, "height": 567}], "variants": {}, "id": "Zbc0eUv1jXytp6GEqwGvRsh-PTjb-xLlLDl976BYG_g"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "7987a74c-04d8-11eb-84ca-0e0ac8b5a78f", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#dadada", "id": "1uiw602", "is_robot_indexable": true, "report_reasons": null, "author": "No_Monitor3155", "discussion_type": null, "num_comments": 2, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uiw602/i_built_a_free_inbrowser_python_learning_platform/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uiw602/i_built_a_free_inbrowser_python_learning_platform/", "subreddit_subscribers": 1493425, "created_utc": 1782747928.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "I'm a coding beginner and after i updated windows the layout started looking different (i can't add pics), i want it to go back to what it used to be as I'm not sure how to use it. ", "author_fullname": "t2_7pzenp33", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "why is pycharm doing this after a windows update", "link_flair_richtext": [{"e": "text", "t": "Discussion"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "discussion", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uj092r", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.08, "author_flair_background_color": null, "subreddit_type": "public", "ups": 0, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Discussion", "can_mod_post": false, "score": 0, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782756782.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "I'm a coding beginner and after i updated windows the layout started looking different (i can't add pics), i want it to go back to what it used to be as I'm not sure how to use it.
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": true, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#f50057", "id": "1uj092r", "is_robot_indexable": true, "report_reasons": null, "author": "Regular_Philosophy_9", "discussion_type": null, "num_comments": 12, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uj092r/why_is_pycharm_doing_this_after_a_windows_update/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uj092r/why_is_pycharm_doing_this_after_a_windows_update/", "subreddit_subscribers": 1493425, "created_utc": 1782756782.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "Hey everyone and all space enthusiasts,\n\n \nOn April 13, 2029, the asteroid **99942 Apophis** will fly past Earth at roughly 38,000 km. Closer than our geostationary satellites! Its discovery history was pretty interesting, because in the beginning there was a 2.7 % chance of an impact (don't worry, it is almost null now). So I decided to use NASA/JPL's SPICE toolkit using Python (`spiceypy`) to calculate the encounter's properties.\n\n \nI documented the entire project in a Jupyter Notebook and put together a complete video walk-through tutorial. Code: [https://github.com/ThomasAlbin/Space-Science-With-Python/blob/main/2026/04\\_SPICE\\_Apohis\\_2029\\_Flyby.ipynb](https://github.com/ThomasAlbin/Space-Science-With-Python/blob/main/2026/04_SPICE_Apohis_2029_Flyby.ipynb)\n\nVideo: [https://www.youtube.com/watch?v=j4mJTR-BTto](https://www.youtube.com/watch?v=j4mJTR-BTto)\n\nWhat the tutorial does (and why it's 30 minutes long \ud83d\ude05):\n\n* Computing the time when Apophis enters Earth's gravitational vicinity (the Sphere of Influece)\n* Computing the closest appraoch (distance and time)\n* Computing how the orbital elements (the dynamical properties to describe orbits in space) change after the encounter with Earth.\n\nBy the way: I used also Python + NASA's Cosmographia to create a nice 3D animation of the encounter :). But I will post it soon on my YouTube channel. Since the corresponding code is a complete mess I won't post it here, because I will only post the animation. I have to clean it up...\n\n \nBest,\n\nThomas (your Cassini/Huygens scientist)", "author_fullname": "t2_67yyoriy", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Computing the close encounter between Apophis and Earth in 2029 (tutorial)", "link_flair_richtext": [{"e": "text", "t": "Tutorial"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "tutorial", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uhutaj", "quarantine": false, "link_flair_text_color": "dark", "upvote_ratio": 0.9, "author_flair_background_color": "#b8001f", "subreddit_type": "public", "ups": 23, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": "01f57bbe-537c-11ee-bb0d-6ef63b2ae5b9", "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Tutorial", "can_mod_post": false, "score": 23, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [{"e": "text", "t": "git push -f"}], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782645910.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "richtext", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Hey everyone and all space enthusiasts,
\n\n
On April 13, 2029, the asteroid 99942 Apophis will fly past Earth at roughly 38,000 km. Closer than our geostationary satellites! Its discovery history was pretty interesting, because in the beginning there was a 2.7 % chance of an impact (don't worry, it is almost null now). So I decided to use NASA/JPL's SPICE toolkit using Python (spiceypy) to calculate the encounter's properties.
\n\n
I documented the entire project in a Jupyter Notebook and put together a complete video walk-through tutorial. Code: https://github.com/ThomasAlbin/Space-Science-With-Python/blob/main/2026/04_SPICE_Apohis_2029_Flyby.ipynb
\n\n
Video: https://www.youtube.com/watch?v=j4mJTR-BTto
\n\n
What the tutorial does (and why it's 30 minutes long \ud83d\ude05):
\n\n
\nComputing the time when Apophis enters Earth's gravitational vicinity (the Sphere of Influece) \nComputing the closest appraoch (distance and time) \nComputing how the orbital elements (the dynamical properties to describe orbits in space) change after the encounter with Earth. \n \n\n
By the way: I used also Python + NASA's Cosmographia to create a nice 3D animation of the encounter :). But I will post it soon on my YouTube channel. Since the corresponding code is a complete mess I won't post it here, because I will only post the animation. I have to clean it up...
\n\n
Best,
\n\n
Thomas (your Cassini/Huygens scientist)
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "7987a74c-04d8-11eb-84ca-0e0ac8b5a78f", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": "git push -f", "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#dadada", "id": "1uhutaj", "is_robot_indexable": true, "report_reasons": null, "author": "MrAstroThomas", "discussion_type": null, "num_comments": 10, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": "light", "permalink": "/r/Python/comments/1uhutaj/computing_the_close_encounter_between_apophis_and/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uhutaj/computing_the_close_encounter_between_apophis_and/", "subreddit_subscribers": 1493425, "created_utc": 1782645910.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "Hi guys this tutorial is about training a neural network in Python to sort lists of numbers using the Gumbel-Sinkhorn architecture from the original 2018 paper.\n\n\n\nGithub: [https://github.com/MurageKibicho/Neural-Sorting-Algorithms-Gumbel-Sinkhorn-Networks/tree/main](https://github.com/MurageKibicho/Neural-Sorting-Algorithms-Gumbel-Sinkhorn-Networks/tree/main)\n\n\n\nWriteup: [https://leetarxiv.substack.com/p/gumbel-sinkhorn-neural-sort](https://leetarxiv.substack.com/p/gumbel-sinkhorn-neural-sort)", "author_fullname": "t2_3xvmpjwh9", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Neural Sorting Algorithms: Gumbel-Sinkhorn Networks", "link_flair_richtext": [{"e": "text", "t": "Tutorial"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "tutorial", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uhkicg", "quarantine": false, "link_flair_text_color": "dark", "upvote_ratio": 0.7, "author_flair_background_color": null, "subreddit_type": "public", "ups": 10, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Tutorial", "can_mod_post": false, "score": 10, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782611904.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "7987a74c-04d8-11eb-84ca-0e0ac8b5a78f", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#dadada", "id": "1uhkicg", "is_robot_indexable": true, "report_reasons": null, "author": "DataBaeBee", "discussion_type": null, "num_comments": 8, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uhkicg/neural_sorting_algorithms_gumbelsinkhorn_networks/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uhkicg/neural_sorting_algorithms_gumbelsinkhorn_networks/", "subreddit_subscribers": 1493425, "created_utc": 1782611904.0, "num_crossposts": 2, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "# Weekly Thread: What's Everyone Working On This Week? \ud83d\udee0\ufe0f\n\nHello r/Python! It's time to share what you've been working on! Whether it's a work-in-progress, a completed masterpiece, or just a rough idea, let us know what you're up to!\n\n# How it Works:\n\n1. **Show & Tell**: Share your current projects, completed works, or future ideas.\n2. **Discuss**: Get feedback, find collaborators, or just chat about your project.\n3. **Inspire**: Your project might inspire someone else, just as you might get inspired here.\n\n# Guidelines:\n\n* Feel free to include as many details as you'd like. Code snippets, screenshots, and links are all welcome.\n* Whether it's your job, your hobby, or your passion project, all Python-related work is welcome here.\n\n# Example Shares:\n\n1. **Machine Learning Model**: Working on a ML model to predict stock prices. Just cracked a 90% accuracy rate!\n2. **Web Scraping**: Built a script to scrape and analyze news articles. It's helped me understand media bias better.\n3. **Automation**: Automated my home lighting with Python and Raspberry Pi. My life has never been easier!\n\nLet's build and grow together! Share your journey and learn from others. Happy coding! \ud83c\udf1f", "author_fullname": "t2_6l4z3", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Sunday Daily Thread: What's everyone working on this week?", "link_flair_richtext": [{"a": ":pythonLogo:", "e": "emoji", "u": "https://emoji.redditmedia.com/8yxdpg6xxnr71_t5_2qh0y/pythonLogo"}, {"e": "text", "t": " Daily Thread"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "daily-thread", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1uhhza7", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.74, "author_flair_background_color": null, "subreddit_type": "public", "ups": 5, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": ":pythonLogo: Daily Thread", "can_mod_post": false, "score": 5, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": true, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1782604808.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Weekly Thread: What's Everyone Working On This Week? \ud83d\udee0\ufe0f \n\n
Hello r/Python ! It's time to share what you've been working on! Whether it's a work-in-progress, a completed masterpiece, or just a rough idea, let us know what you're up to!
\n\n
How it Works: \n\n
\nShow & Tell : Share your current projects, completed works, or future ideas. \nDiscuss : Get feedback, find collaborators, or just chat about your project. \nInspire : Your project might inspire someone else, just as you might get inspired here. \n \n\n
Guidelines: \n\n
\nFeel free to include as many details as you'd like. Code snippets, screenshots, and links are all welcome. \nWhether it's your job, your hobby, or your passion project, all Python-related work is welcome here. \n \n\n
Example Shares: \n\n
\nMachine Learning Model : Working on a ML model to predict stock prices. Just cracked a 90% accuracy rate! \nWeb Scraping : Built a script to scrape and analyze news articles. It's helped me understand media bias better. \nAutomation : Automated my home lighting with Python and Raspberry Pi. My life has never been easier! \n \n\n
Let's build and grow together! Share your journey and learn from others. Happy coding! \ud83c\udf1f
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "6c024934-de3f-11ea-a05a-0ea86b2be9a1", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": "moderator", "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#00a6a5", "id": "1uhhza7", "is_robot_indexable": true, "report_reasons": null, "author": "AutoModerator", "discussion_type": null, "num_comments": 5, "send_replies": false, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1uhhza7/sunday_daily_thread_whats_everyone_working_on/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1uhhza7/sunday_daily_thread_whats_everyone_working_on/", "subreddit_subscribers": 1493425, "created_utc": 1782604808.0, "num_crossposts": 0, "media": null, "is_video": false}}, {"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "Hi guys this tutorial is about handling errors as value in Python or in nerd language monadic error handling pattern in Python using [katharos](https://github.com/kamalfarahani/katharos) library:\n\n[https://theblog.info/posts/handling-errors-as-values-in-python-with-katharos](https://theblog.info/posts/handling-errors-as-values-in-python-with-katharos)", "author_fullname": "t2_rkfihkkzk", "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Handling Errors as Values in Python with Katharos", "link_flair_richtext": [{"e": "text", "t": "Tutorial"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "tutorial", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1ugsdwl", "quarantine": false, "link_flair_text_color": "dark", "upvote_ratio": 0.8, "author_flair_background_color": null, "subreddit_type": "public", "ups": 32, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "user_reports": [], "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Tutorial", "can_mod_post": false, "score": 32, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": false, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "post_hint": "self", "content_categories": null, "is_self": true, "mod_note": null, "created": 1782532952.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "preview": {"images": [{"source": {"url": "https://external-preview.redd.it/2Eh5ntKTVDMhEisUeP53EhuqwJJIX0ynlVvClZhlpoc.png?auto=webp&s=e1b494e288c1332109f2282e0eda050c121c7f94", "width": 1200, "height": 600}, "resolutions": [{"url": "https://external-preview.redd.it/2Eh5ntKTVDMhEisUeP53EhuqwJJIX0ynlVvClZhlpoc.png?width=108&crop=smart&auto=webp&s=ce16fd58cc96586a6f4e09bca243f41637f64836", "width": 108, "height": 54}, {"url": "https://external-preview.redd.it/2Eh5ntKTVDMhEisUeP53EhuqwJJIX0ynlVvClZhlpoc.png?width=216&crop=smart&auto=webp&s=c3fb804da74b957b5369749521851ac924019025", "width": 216, "height": 108}, {"url": "https://external-preview.redd.it/2Eh5ntKTVDMhEisUeP53EhuqwJJIX0ynlVvClZhlpoc.png?width=320&crop=smart&auto=webp&s=f77c73faba71e3855e963638b6e281ae8ba9326b", "width": 320, "height": 160}, {"url": "https://external-preview.redd.it/2Eh5ntKTVDMhEisUeP53EhuqwJJIX0ynlVvClZhlpoc.png?width=640&crop=smart&auto=webp&s=12389bfb3289b1adbe89c931c1868cfb229f14cd", "width": 640, "height": 320}, {"url": "https://external-preview.redd.it/2Eh5ntKTVDMhEisUeP53EhuqwJJIX0ynlVvClZhlpoc.png?width=960&crop=smart&auto=webp&s=d129a03ff5a59c64480e409707497af6c212c840", "width": 960, "height": 480}, {"url": "https://external-preview.redd.it/2Eh5ntKTVDMhEisUeP53EhuqwJJIX0ynlVvClZhlpoc.png?width=1080&crop=smart&auto=webp&s=db5f489c84a30dd6dbc69accc931aebd60168702", "width": 1080, "height": 540}], "variants": {}, "id": "2Eh5ntKTVDMhEisUeP53EhuqwJJIX0ynlVvClZhlpoc"}], "enabled": false}, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "7987a74c-04d8-11eb-84ca-0e0ac8b5a78f", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#dadada", "id": "1ugsdwl", "is_robot_indexable": true, "report_reasons": null, "author": "EntryNo8040", "discussion_type": null, "num_comments": 9, "send_replies": true, "contest_mode": false, "mod_reports": [], "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1ugsdwl/handling_errors_as_values_in_python_with_katharos/", "stickied": false, "url": "https://www.reddit.com/r/Python/comments/1ugsdwl/handling_errors_as_values_in_python_with_katharos/", "subreddit_subscribers": 1493425, "created_utc": 1782532952.0, "num_crossposts": 2, "media": null, "is_video": false}}], "before": null}}
\ No newline at end of file
diff --git a/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_post.json b/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_post.json
new file mode 100644
index 000000000..d2627e6e9
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/reddit/fixtures/sample_post.json
@@ -0,0 +1 @@
+[{"kind": "Listing", "data": {"after": null, "dist": 1, "modhash": "", "geo_filter": "", "children": [{"kind": "t3", "data": {"approved_at_utc": null, "subreddit": "Python", "selftext": "Post all of your code/projects/showcases/AI slop here. \n\nRecycles once a month.", "user_reports": [], "saved": false, "mod_reason_title": null, "gilded": 0, "clicked": false, "title": "Showcase Thread", "link_flair_richtext": [{"e": "text", "t": "Showcase"}], "subreddit_name_prefixed": "r/Python", "hidden": false, "pwls": 6, "link_flair_css_class": "showcase", "downs": 0, "thumbnail_height": null, "top_awarded_type": null, "hide_score": false, "name": "t3_1tws1w7", "quarantine": false, "link_flair_text_color": "light", "upvote_ratio": 0.87, "author_flair_background_color": null, "subreddit_type": "public", "ups": 27, "total_awards_received": 0, "media_embed": {}, "thumbnail_width": null, "author_flair_template_id": null, "is_original_content": false, "author_fullname": "t2_6l4z3", "secure_media": null, "is_reddit_media_domain": false, "is_meta": false, "category": null, "secure_media_embed": {}, "link_flair_text": "Showcase", "can_mod_post": false, "score": 27, "approved_by": null, "is_created_from_ads_ui": false, "author_premium": true, "thumbnail": "self", "edited": false, "author_flair_css_class": null, "author_flair_richtext": [], "gildings": {}, "content_categories": null, "is_self": true, "mod_note": null, "created": 1780589106.0, "link_flair_type": "richtext", "wls": 6, "removed_by_category": null, "banned_by": null, "author_flair_type": "text", "domain": "self.Python", "allow_live_comments": false, "selftext_html": "Post all of your code/projects/showcases/AI slop here.
\n\n
Recycles once a month.
\n
", "likes": null, "suggested_sort": null, "banned_at_utc": null, "view_count": null, "archived": false, "no_follow": false, "is_crosspostable": false, "pinned": false, "over_18": false, "all_awardings": [], "awarders": [], "media_only": false, "link_flair_template_id": "f35fb004-c1ff-11ee-8305-565bc5d0cc73", "can_gild": false, "spoiler": false, "locked": false, "author_flair_text": null, "treatment_tags": [], "visited": false, "removed_by": null, "num_reports": null, "distinguished": null, "subreddit_id": "t5_2qh0y", "author_is_blocked": false, "mod_reason_by": null, "removal_reason": null, "link_flair_background_color": "#ff66ac", "id": "1tws1w7", "is_robot_indexable": true, "num_duplicates": 0, "report_reasons": null, "author": "AutoModerator", "discussion_type": null, "num_comments": 207, "send_replies": true, "media": null, "contest_mode": false, "author_patreon_flair": false, "author_flair_text_color": null, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/", "stickied": true, "url": "https://www.reddit.com/r/Python/comments/1tws1w7/showcase_thread/", "subreddit_subscribers": 1493425, "created_utc": 1780589106.0, "num_crossposts": 0, "mod_reports": [], "is_video": false}}], "before": null}}, {"kind": "Listing", "data": {"after": null, "dist": null, "modhash": "", "geo_filter": "", "children": [{"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "oprc6qa", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "hannah_G_", "can_mod_post": false, "created_utc": 1780597457.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 4, "author_fullname": "t2_gjk10lou", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "**db-git**\u00a0\\- keep your local database in sync with your git branches.\n\n# What My Project Does\n\n`db-git`\u00a0is a developer tool for projects where database state follows code changes: schema migrations, seed data, experimental feature work, and branch switching during reviews. It installs git\u00a0`post-checkout`\u00a0hook and keeps your local database aligned with the branch you are working on.\n\n* Two workflows:\n * `shared`: one database, saved and restored per branch\n * `per-branch`: one database per branch\n* PostgreSQL support today, with plans for more database backends\n* Two PostgreSQL snapshot strategies:\n * `template`: fast database clones using\u00a0`CREATE DATABASE ... TEMPLATE`\n * `pgdump`: portable snapshots using\u00a0`pg_dump`\u00a0and\u00a0`pg_restore`\n\n# Target Audience\n\nBackend and full-stack developers who run databases locally and switch branches often, especially on projects where migrations or seed data diverge between branches. It's a local development tool.\n\n# Comparison\n\nThe main things that set\u00a0`db-git`\u00a0apart from existing tools are:\n\n1. It lets you choose per project,\u00a0`shared`\u00a0vs\u00a0`per-branch`, and\u00a0`template`\u00a0vs\u00a0`pgdump`.\n2. It ties database state directly to checkout.\n3. It is not tied to a specific database engine. PostgreSQL is the first supported backend, but the design isn't Postgres-specific, and more databases are planned.\n\n`uv tool install db-git`\n\nGitHub:\u00a0[https://github.com/earthcomfy/db-git](https://github.com/earthcomfy/db-git)\n\nAny feedback is very welcome!", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_oprc6qa", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "db-git \u00a0- keep your local database in sync with your git branches.
\n\n
What My Project Does \n\n
db-git\u00a0is a developer tool for projects where database state follows code changes: schema migrations, seed data, experimental feature work, and branch switching during reviews. It installs git\u00a0post-checkout\u00a0hook and keeps your local database aligned with the branch you are working on.
\n\n
\nTwo workflows:\n\n\nshared: one database, saved and restored per branch \nper-branch: one database per branch \n \nPostgreSQL support today, with plans for more database backends \nTwo PostgreSQL snapshot strategies:\n\n\ntemplate: fast database clones using\u00a0CREATE DATABASE ... TEMPLATE \npgdump: portable snapshots using\u00a0pg_dump\u00a0and\u00a0pg_restore \n \n \n\n
Target Audience \n\n
Backend and full-stack developers who run databases locally and switch branches often, especially on projects where migrations or seed data diverge between branches. It's a local development tool.
\n\n
Comparison \n\n
The main things that set\u00a0db-git\u00a0apart from existing tools are:
\n\n
\nIt lets you choose per project,\u00a0shared\u00a0vs\u00a0per-branch, and\u00a0template\u00a0vs\u00a0pgdump. \nIt ties database state directly to checkout. \nIt is not tied to a specific database engine. PostgreSQL is the first supported backend, but the design isn't Postgres-specific, and more databases are planned. \n \n\n
uv tool install db-git
\n\n
GitHub:\u00a0https://github.com/earthcomfy/db-git
\n\n
Any feedback is very welcome!
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/oprc6qa/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780597457.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 4}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "opt03ob", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "emnoleg", "can_mod_post": false, "created_utc": 1780615070.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 3, "author_fullname": "t2_25q8p7gsgg", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "# Tired of screen-sharing 3D models in meetings, so I made them float on my webcam instead\n\n \nDrop any\u00a0*.glb*\u00a0file in a folder, pick it from the tray icon, and it appears on your webcam. **Move it, rotate it, scale it**. Your meeting app sees it as a normal camera. Nothing else to open or manage.\n\nGood for product demos, design reviews, showing off Blender exports, branded content on calls. Anything where you want the model visible **without screen-sharing**.\n\nWorks in Zoom, Meet, Teams, Discord, pretty much anything that accepts a webcam input.\n\nRenders the 3D layer **offscreen** and caches it. The model only re-renders when you move it, so the webcam stays smooth on any machine. Python,\u00a0*pyrender*,\u00a0*pyvirtualcam*\u00a0under the hood.\u00a0Open source, setup is one script.\n\n[Project Link](https://github.com/aadi-joshi/cam3)\n\n\n\n*Demo model: V1 from ULTRAKILL on Sketchfab (CC BY). Character by Hakita / New Blood Interactive. Not included in the repo.*\u00a0[*https://sketchfab.com/3d-models/v1-ultrakill-d951a08a8f50412d84e262bad887b285*](https://sketchfab.com/3d-models/v1-ultrakill-d951a08a8f50412d84e262bad887b285)", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_opt03ob", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "Tired of screen-sharing 3D models in meetings, so I made them float on my webcam instead \n\n
Drop any\u00a0.glb \u00a0file in a folder, pick it from the tray icon, and it appears on your webcam. Move it, rotate it, scale it . Your meeting app sees it as a normal camera. Nothing else to open or manage.
\n\n
Good for product demos, design reviews, showing off Blender exports, branded content on calls. Anything where you want the model visible without screen-sharing .
\n\n
Works in Zoom, Meet, Teams, Discord, pretty much anything that accepts a webcam input.
\n\n
Renders the 3D layer offscreen and caches it. The model only re-renders when you move it, so the webcam stays smooth on any machine. Python,\u00a0pyrender ,\u00a0pyvirtualcam \u00a0under the hood.\u00a0Open source, setup is one script.
\n\n
Project Link
\n\n
Demo model: V1 from ULTRAKILL on Sketchfab (CC BY). Character by Hakita / New Blood Interactive. Not included in the repo. \u00a0https://sketchfab.com/3d-models/v1-ultrakill-d951a08a8f50412d84e262bad887b285
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/opt03ob/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780615070.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 3}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "oqcmdwf", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "Comfortable-Noise144", "can_mod_post": false, "created_utc": 1780872157.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 3, "author_fullname": "t2_7qhbipym", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "Hi,\n\n**I built a VS Code extension to helt prevent losing track of complex math expressions in Python**\n\nWhen you're writing something like\u00a0`(a * np.sqrt(x**2 + y**2)) / (2 * np.pi * sigma**2)`\u00a0it gets really hard to mentally parse what that formula actually is. In Maple or Mathematica you see a clean equation. In Python you just see a wall of parentheses.\n\nSo I built a VS Code extension that lets you select any expression and instantly renders it as a proper math equation. It supports NumPy, SymPy, SciPy, matrices, integrals, derivatives and more.\n\nThis extension is intended for everyone, beginner programmers as well as experienced programmers, it just meant to give a better overview of math expressions. \n\nFree on the marketplace, search\u00a0**\"Python Expression Visualizer\"**\u00a0in VS Code extensions.\n\nThis is a link to the Github repository: \n[https://github.com/NickG-DK/python-expression-visualizer](https://github.com/NickG-DK/python-expression-visualizer)\n\n \nWould love feedback, as this is my first extension ever.", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_oqcmdwf", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "Hi,
\n\n
I built a VS Code extension to helt prevent losing track of complex math expressions in Python
\n\n
When you're writing something like\u00a0(a * np.sqrt(x**2 + y**2)) / (2 * np.pi * sigma**2)\u00a0it gets really hard to mentally parse what that formula actually is. In Maple or Mathematica you see a clean equation. In Python you just see a wall of parentheses.
\n\n
So I built a VS Code extension that lets you select any expression and instantly renders it as a proper math equation. It supports NumPy, SymPy, SciPy, matrices, integrals, derivatives and more.
\n\n
This extension is intended for everyone, beginner programmers as well as experienced programmers, it just meant to give a better overview of math expressions.
\n\n
Free on the marketplace, search\u00a0"Python Expression Visualizer" \u00a0in VS Code extensions.
\n\n
This is a link to the Github repository: \nhttps://github.com/NickG-DK/python-expression-visualizer
\n\n
Would love feedback, as this is my first extension ever.
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/oqcmdwf/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780872157.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 3}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "oprmzm5", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "anton273", "can_mod_post": false, "created_utc": 1780600389.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 2, "author_fullname": "t2_5n55nn0l", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "I built watchpoints (data breakpoints) for PyCharm - lets you break on a value change, not a line. \n \nFree and open source, every developer deserves a great debugging experience. \nLink: [https://plugins.jetbrains.com/plugin/32087-python-watchpoint](https://plugins.jetbrains.com/plugin/32087-python-watchpoint)", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_oprmzm5", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/oprmzm5/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780600389.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 2}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "opro849", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "hmoein", "can_mod_post": false, "created_utc": 1780600729.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 2, "author_fullname": "t2_jeyhnly", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "[Grizzlars](https://github.com/NavodPeiris/grizzlars)\u00a0is Python bindings for the C++ DataFrame with an interface almost identical to Pandas. It is a bit of work in progress. It depends on an older version of C++ DataFrame than the latest release. But it is moving along surely.", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_opro849", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "Grizzlars \u00a0is Python bindings for the C++ DataFrame with an interface almost identical to Pandas. It is a bit of work in progress. It depends on an older version of C++ DataFrame than the latest release. But it is moving along surely.
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/opro849/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780600729.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 2}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": {"kind": "Listing", "data": {"after": null, "dist": null, "modhash": "", "geo_filter": "", "children": [{"kind": "more", "data": {"count": 2, "name": "t1_oqtbh24", "id": "oqtbh24", "parent_id": "t1_oprt22n", "depth": 1, "children": ["oqtbh24"]}}], "before": null}}, "user_reports": [], "saved": false, "id": "oprt22n", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "em_el_k0b01101011", "can_mod_post": false, "created_utc": 1780602067.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 2, "author_fullname": "t2_v8e339ps", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "**HyperWeave** is a Python library that generates self-contained SVGs: profile cards, star-history charts, small dashboards, receipts from AI coding sessions, and more. No JS, no external deps. Each one is a single SVG that renders anywhere an image does (GitHub READMEs, Slack, Notion, docs).\n\nDrive it from a CLI, an HTTP API, or an MCP server. It can also pull live data into the artifact, so a card can show a package's real download trend or latest version (PyPI, npm, crates, Hugging Face, arXiv, and a few others) without fetching anything yourself.\n\nThere's a hook for AI coding sessions too: each one ends with a receipt of tokens, cost, and tool calls.\n\nFastAPI + Pydantic + Jinja2 + Typer under the hood.\n\n`pip install hyperweave`\n\nGitHub: https://github.com/InnerAura/hyperweave\n\nPyPI: https://pypi.org/project/hyperweave/\n\nStill early, any feedback welcome. Cheers.", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_oprt22n", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "HyperWeave is a Python library that generates self-contained SVGs: profile cards, star-history charts, small dashboards, receipts from AI coding sessions, and more. No JS, no external deps. Each one is a single SVG that renders anywhere an image does (GitHub READMEs, Slack, Notion, docs).
\n\n
Drive it from a CLI, an HTTP API, or an MCP server. It can also pull live data into the artifact, so a card can show a package's real download trend or latest version (PyPI, npm, crates, Hugging Face, arXiv, and a few others) without fetching anything yourself.
\n\n
There's a hook for AI coding sessions too: each one ends with a receipt of tokens, cost, and tool calls.
\n\n
FastAPI + Pydantic + Jinja2 + Typer under the hood.
\n\n
pip install hyperweave
\n\n
GitHub: https://github.com/InnerAura/hyperweave
\n\n
PyPI: https://pypi.org/project/hyperweave/
\n\n
Still early, any feedback welcome. Cheers.
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/oprt22n/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780602067.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 2}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "opseoev", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "Beginning-Fruit-1397", "can_mod_post": false, "created_utc": 1780608167.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 2, "author_fullname": "t2_ndboruet", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "If you like itertools, fluent interfaces, Rust Result and Option, or the libraries like returns or more-itertools, check out my project pyochain!\u00a0\n\nSince the last showcase, more code have been moved to Rust which led to substantial performance improvements, and new constructs have been added, notably:\n\n- SliceView, zero copy, efficient view/slice of a Sequence\n- StableSet, a set that keep the original Iterable ordering when created.\n- Deque, pyochain version of collections.deque\u00a0\n\nIt's now as well dependency-free!\n\n\u00a0All the code coming from cytoolz (cython toolz version) has been replaced by the same functionality ported in Rust.\n\nLinks:\n\nGithub:\nhttps://github.com/OutSquareCapital/pyochain\n\nPypi:\nhttps://pypi.org/project/pyochain/\n\nMy last showcase on this sub:\nhttps://www.reddit.com/r/Python/comments/1q61bzg/pyochain_rustlike_iterator_result_and_option_in/\n\nComparison vs similar libraries, where's it's been ranked best choice (the post is NOT from me):\nhttps://www.reddit.com/r/Python/comments/1rj3ct7/comment/o8aordo/?context=3\n", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_opseoev", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "If you like itertools, fluent interfaces, Rust Result and Option, or the libraries like returns or more-itertools, check out my project pyochain!\u00a0
\n\n
Since the last showcase, more code have been moved to Rust which led to substantial performance improvements, and new constructs have been added, notably:
\n\n
\nSliceView, zero copy, efficient view/slice of a Sequence \nStableSet, a set that keep the original Iterable ordering when created. \nDeque, pyochain version of collections.deque\u00a0 \n \n\n
It's now as well dependency-free!
\n\n
\u00a0All the code coming from cytoolz (cython toolz version) has been replaced by the same functionality ported in Rust.
\n\n
Links:
\n\n
Github:\nhttps://github.com/OutSquareCapital/pyochain
\n\n
Pypi:\nhttps://pypi.org/project/pyochain/
\n\n
My last showcase on this sub:\nhttps://www.reddit.com/r/Python/comments/1q61bzg/pyochain_rustlike_iterator_result_and_option_in/
\n\n
Comparison vs similar libraries, where's it's been ranked best choice (the post is NOT from me):\nhttps://www.reddit.com/r/Python/comments/1rj3ct7/comment/o8aordo/?context=3
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/opseoev/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780608167.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 2}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "oq8egkc", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "iliketrains166", "can_mod_post": false, "created_utc": 1780820743.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 2, "author_fullname": "t2_6i4840c2", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "**peeq: A CLI to investigate Python package metadata, dependencies, vulnerabilities and more**\n\nI work with Python packages as published artifacts a lot: building them from source, inspecting source, applying patches, debugging resolver issues, and checking what actually shipped in an sdist or wheel.\n\nThe information is usually available, but it\u2019s scattered across PyPI metadata, source repos, distribution archives, OSV, resolver output, and other places. Source repos are especially tricky because the main branch often does not match the release I\u2019m investigating.\n\nThat led me to build **peeq** \\- a CLI for inspecting Python package metadata, dependencies, files, and versions from PyPI or private indexes, and known vulnerabilities via OSV, without installing or executing the package being inspected.\n\n\n\n**Links:**\n\nGitHub: [https://github.com/MichaelYochpaz/peeq](https://github.com/MichaelYochpaz/peeq)\n\nDocs: [https://peeq.michaelyo.dev](https://peeq.michaelyo.dev)\n\nPyPI: [https://pypi.org/project/peeq](https://pypi.org/project/peeq)\n\n\n\nExample commands you can try (requires uv):\n\n`uvx peeq info requests --full`\n\n`uvx peeq deps docling --version 2.20.0 --diff 2.30.0`\n\n`uvx peeq cat requests pyproject.toml`\n\n`uvx peeq vulns requests --version 2.31.0`\n\n`uvx peeq conflicts kubernetes==35.0.0a1 kfp==2.16.0`\n\n`uvx peeq why \"flask>=3.0\" -d markupsafe`\n\n\n\npeeq is written for both humans and agents, and ships [with a native Agent Skill](https://peeq.michaelyo.dev/ai-agents/skill/).\n\nWith the skill installed, coding agents can answer package questions by inspecting the published metadata/artifacts themselves instead of guessing from memory or installing packages.\n\nWith peeq's skill installed, you can ask your agent questions in natural language like:\n\n\\- \"Does \\`docling\\` depend on \\`pydantic\\`, and through which path?\"\n\n\\- \"Why can't \\`kubernetes==35.0.0a1\\` be installed together with \\`kfp==2.16.0\\`?\"\n\n\\- \"What changed in \\`docling\\` dependencies between versions 2.20.0 and 2.30.0?\"\n\n\\- \"Which build backend does the \\`httpx\\` Python package use?\"\n\nTransparency note: The project relies heavily on AI coding agents for development. I\u2019m a Python software engineer and the maintainer of the project; design choices, code changes, docs, and releases are all manually reviewed and tested by me.\n\n\n\nFeedback (either here or on GitHub) is welcome!\n\n", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_oq8egkc", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "peeq: A CLI to investigate Python package metadata, dependencies, vulnerabilities and more
\n\n
I work with Python packages as published artifacts a lot: building them from source, inspecting source, applying patches, debugging resolver issues, and checking what actually shipped in an sdist or wheel.
\n\n
The information is usually available, but it\u2019s scattered across PyPI metadata, source repos, distribution archives, OSV, resolver output, and other places. Source repos are especially tricky because the main branch often does not match the release I\u2019m investigating.
\n\n
That led me to build peeq - a CLI for inspecting Python package metadata, dependencies, files, and versions from PyPI or private indexes, and known vulnerabilities via OSV, without installing or executing the package being inspected.
\n\n
Links:
\n\n
GitHub: https://github.com/MichaelYochpaz/peeq
\n\n
Docs: https://peeq.michaelyo.dev
\n\n
PyPI: https://pypi.org/project/peeq
\n\n
Example commands you can try (requires uv):
\n\n
uvx peeq info requests --full
\n\n
uvx peeq deps docling --version 2.20.0 --diff 2.30.0
\n\n
uvx peeq cat requests pyproject.toml
\n\n
uvx peeq vulns requests --version 2.31.0
\n\n
uvx peeq conflicts kubernetes==35.0.0a1 kfp==2.16.0
\n\n
uvx peeq why "flask>=3.0" -d markupsafe
\n\n
peeq is written for both humans and agents, and ships with a native Agent Skill .
\n\n
With the skill installed, coding agents can answer package questions by inspecting the published metadata/artifacts themselves instead of guessing from memory or installing packages.
\n\n
With peeq's skill installed, you can ask your agent questions in natural language like:
\n\n
- "Does `docling` depend on `pydantic`, and through which path?"
\n\n
- "Why can't `kubernetes==35.0.0a1` be installed together with `kfp==2.16.0`?"
\n\n
- "What changed in `docling` dependencies between versions 2.20.0 and 2.30.0?"
\n\n
- "Which build backend does the `httpx` Python package use?"
\n\n
Transparency note: The project relies heavily on AI coding agents for development. I\u2019m a Python software engineer and the maintainer of the project; design choices, code changes, docs, and releases are all manually reviewed and tested by me.
\n\n
Feedback (either here or on GitHub) is welcome!
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/oq8egkc/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780820743.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 2}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "ot36qxq", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "WompTitanium", "can_mod_post": false, "created_utc": 1782115044.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 2, "author_fullname": "t2_2agi8wikb3", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "Built a CLI tool that maps any codebase instantly, no API keys, fully offline\nJust run pip install codemappr then codemappr scan inside any project folder.\nIt detects 20+ project types (React, Django, Rust, Flutter, etc.) and gives you a full architecture breakdown in seconds. Outputs to terminal, Markdown, or HTML.\nNo setup, no API keys, no internet needed.\nGitHub: https://github.com/erensh27/CodeMappr[codemappr](https://github.com/erensh27/CodeMappr)", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_ot36qxq", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "Built a CLI tool that maps any codebase instantly, no API keys, fully offline\nJust run pip install codemappr then codemappr scan inside any project folder.\nIt detects 20+ project types (React, Django, Rust, Flutter, etc.) and gives you a full architecture breakdown in seconds. Outputs to terminal, Markdown, or HTML.\nNo setup, no API keys, no internet needed.\nGitHub: https://github.com/erensh27/CodeMappr[codemappr](https://github.com/erensh27/CodeMappr)
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/ot36qxq/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1782115044.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 2}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "ops17j6", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "iamnotafermiparadox", "can_mod_post": false, "created_utc": 1780604316.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 1, "author_fullname": "t2_fd5z8ffe", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "MailTriage is a local, batch-oriented IMAP email triage tool. \n\nThis tool allows me to generate an html page of email received over the last 24 hours and see the subject and snippet in a customized order. Priority senders are first, others are next, followed by a list of email sent by systems. Senders that I don't want to see are not listed. \n \nI added the ability to send certain email to a llm to be summarized. Those emails roll into a md file for todos. Once I mark the todo as done, the tool removes that entry and archives it.\n\nBitwarden CLI pull email credentials. Other methods could be used.\n\n[https://github.com/rogdooley/MailTriage](https://github.com/rogdooley/MailTriage)\n\nCodeGuage: CodeGauge is a deterministic, local-first code quality and security analysis platform. I had this built so I could monitor the \"quality\" of LLM generated code based on linters and static analysis tools.\n\n \n[https://github.com/rogdooley/CodeGauge](https://github.com/rogdooley/CodeGauge)", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_ops17j6", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "MailTriage is a local, batch-oriented IMAP email triage tool.
\n\n
This tool allows me to generate an html page of email received over the last 24 hours and see the subject and snippet in a customized order. Priority senders are first, others are next, followed by a list of email sent by systems. Senders that I don't want to see are not listed.
\n\n
I added the ability to send certain email to a llm to be summarized. Those emails roll into a md file for todos. Once I mark the todo as done, the tool removes that entry and archives it.
\n\n
Bitwarden CLI pull email credentials. Other methods could be used.
\n\n
https://github.com/rogdooley/MailTriage
\n\n
CodeGuage: CodeGauge is a deterministic, local-first code quality and security analysis platform. I had this built so I could monitor the "quality" of LLM generated code based on linters and static analysis tools.
\n\n
https://github.com/rogdooley/CodeGauge
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/ops17j6/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780604316.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 1}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": {"kind": "Listing", "data": {"after": null, "dist": null, "modhash": "", "geo_filter": "", "children": [{"kind": "more", "data": {"count": 6, "name": "t1_opuqgy8", "id": "opuqgy8", "parent_id": "t1_opt5ogy", "depth": 1, "children": ["opuqgy8"]}}], "before": null}}, "user_reports": [], "saved": false, "id": "opt5ogy", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "Charming_Guidance_76", "can_mod_post": false, "created_utc": 1780617021.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 1, "author_fullname": "t2_7t4cf11b", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "**EDOF, the document toolkit I wish had existed when I started**\n\nThis is one of those \"got fed up, built my own thing\" stories. I make card games, and at work I deal with a lot of document stuff (invoices, certificates, QR labels), and every tool I tried was missing exactly one thing I needed. One had no auto-shrink text. One had no curves. One had nice Photoshop-style effects but you couldn't script it. One couldn't even center text vertically in a box (still bitter about that one). I was tired of duct-taping three tools plus glue code together for every job, so I built EDOF. It took a while. It's on PyPI now.\n\n**What My Project Does**\n\nYou build a document in plain Python, or you open the same file in a visual editor (PyQt6) and drag things around. Both sides read and write the exact same file, so nothing gets lost going back and forth. The part I'm happiest with is that it's literally just `import edof`, so you can drop the whole engine into your own app. The editor I ship is just one program that happens to use it.\n\n import edof\n doc = edof.new(width=210, height=297, title=\"Hello\")\n page = doc.add_page(dpi=300)\n page.add_textbox(15, 15, 180, 12, \"Hello world!\")\n doc.export_pdf(\"hello.pdf\") # vector PDF, no extra deps\n \n\nI mostly use it for batch stuff: feed it a CSV, get a few hundred finished PDFs out. That's how I print my own card games now instead of fighting Photoshop layers at 2am. It does text, images, shapes, paths, QR codes, variables with {placeholders}, the auto-shrink and centering I kept missing elsewhere, and it exports to PDF, PNG, SVG and RTF. It can also read and write Word files now, which I'll get to.\n\n**Target Audience**\n\nMe first, honestly. But also anyone in Python who has to crank out documents (labels, invoices, certificates, cards, batch jobs from data), or who wants a document engine living inside their own app instead of bolting on a separate tool. It's MIT, it's on PyPI, I use it for real work. Fair warning on maturity: the core and the editor are solid, the Word part is new and basic, and tables are half-built, so don't lean on those yet.\n\n**Comparison**\n\nReportLab is great but it's code-only and you place everything by coordinates, there's no visual side to hand anyone. python-docx is perfect if you only ever touch Word. LaTeX is its own universe. And none of the visual tools (InDesign and friends) let you just import them into a Python script. EDOF is the in-between I wanted: write it in code or edit it by hand, same file, and embed it anywhere.\n\nThe Word support nearly broke me, by the way. New and genuine respect for whoever maintains .docx tooling for a living. It's basic both directions for now, and it straight up tells you what it can't carry over instead of silently mangling your document.\n\nRepo's here if you want to poke at it: [https://github.com/DavidSchobl/edof](https://github.com/DavidSchobl/edof) . Happy to answer anything, and I'm genuinely after ideas for what to add next.", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_opt5ogy", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "EDOF, the document toolkit I wish had existed when I started
\n\n
This is one of those "got fed up, built my own thing" stories. I make card games, and at work I deal with a lot of document stuff (invoices, certificates, QR labels), and every tool I tried was missing exactly one thing I needed. One had no auto-shrink text. One had no curves. One had nice Photoshop-style effects but you couldn't script it. One couldn't even center text vertically in a box (still bitter about that one). I was tired of duct-taping three tools plus glue code together for every job, so I built EDOF. It took a while. It's on PyPI now.
\n\n
What My Project Does
\n\n
You build a document in plain Python, or you open the same file in a visual editor (PyQt6) and drag things around. Both sides read and write the exact same file, so nothing gets lost going back and forth. The part I'm happiest with is that it's literally just import edof, so you can drop the whole engine into your own app. The editor I ship is just one program that happens to use it.
\n\n
import edof\ndoc = edof.new(width=210, height=297, title="Hello")\npage = doc.add_page(dpi=300)\npage.add_textbox(15, 15, 180, 12, "Hello world!")\ndoc.export_pdf("hello.pdf") # vector PDF, no extra deps\n\n\n
I mostly use it for batch stuff: feed it a CSV, get a few hundred finished PDFs out. That's how I print my own card games now instead of fighting Photoshop layers at 2am. It does text, images, shapes, paths, QR codes, variables with {placeholders}, the auto-shrink and centering I kept missing elsewhere, and it exports to PDF, PNG, SVG and RTF. It can also read and write Word files now, which I'll get to.
\n\n
Target Audience
\n\n
Me first, honestly. But also anyone in Python who has to crank out documents (labels, invoices, certificates, cards, batch jobs from data), or who wants a document engine living inside their own app instead of bolting on a separate tool. It's MIT, it's on PyPI, I use it for real work. Fair warning on maturity: the core and the editor are solid, the Word part is new and basic, and tables are half-built, so don't lean on those yet.
\n\n
Comparison
\n\n
ReportLab is great but it's code-only and you place everything by coordinates, there's no visual side to hand anyone. python-docx is perfect if you only ever touch Word. LaTeX is its own universe. And none of the visual tools (InDesign and friends) let you just import them into a Python script. EDOF is the in-between I wanted: write it in code or edit it by hand, same file, and embed it anywhere.
\n\n
The Word support nearly broke me, by the way. New and genuine respect for whoever maintains .docx tooling for a living. It's basic both directions for now, and it straight up tells you what it can't carry over instead of silently mangling your document.
\n\n
Repo's here if you want to poke at it: https://github.com/DavidSchobl/edof . Happy to answer anything, and I'm genuinely after ideas for what to add next.
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/opt5ogy/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780617021.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 1}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "opul15z", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "Pytrithon", "can_mod_post": false, "created_utc": 1780636699.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 1, "author_fullname": "t2_yzvpcs5sb", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "## Introduction\n\nI have already introduced Pytrithon three times on Reddit.\nSee:\n\nhttps://www.reddit.com/r/Python/comments/1q8dwsm/pytrithon_v119_graphical_petri_net_inspired_agent/\nhttps://www.reddit.com/r/Python/comments/1nr3qvm/pytrithon_graphical_petrinet_inspired_agent/\nhttps://www.reddit.com/r/Python/comments/1mx9w5r/graphical_petrinet_inspired_agent_oriented/\n\n## What My Project Does\n\nPytrithon is a graphical Petri net inspired agent oriented programming language based on Python.\nIt allows writing code as a two dimensional graph of interconnected elements and separates data as Places and code as Transitions. Inter Agent communication and GUI widgets are first class components of the language. Through the Monipulator, Agents can be monitored and manipulated.\n\n## Target Audience\n\nThe target audience is both experienced and novice programmers who want to try something new.\n\n## Why I Built It\n\nI realized the power of Petri net inspired programming and the joy of having a more expressive way to specify control flow.\n\n## Comparison\n\nThere are no other visual programming languages which embed actual code into their graphs.\n\n## How To Explore\n\nTo run all included example Agents you need at least Python 3.10 installed. To install all dependencies, run the 'install' script. Then you can start up a Nexus with a Monipulator by running the 'pytrithon' script, where you can start Agents through opening them with 'crtl-o' twice and hitting the 'Open Agent' button. You can also directly specify which Agents to run through the command line by starting a Nexus, Monipulator, and Agents in one single command: 'python nexus -m \\ \\'.\n\nRecommended example Agents to run are: 'basic', 'prodcons', 'address', 'kata', 'calculator', 'kniffel', 'guess', 'yahtzeeserver' + multiple 'yahtzee', 'pokerserver' + multiple 'poker', 'chatserver' + multiple 'chat', 'image', 'jobapplic', and 'nethods'. As a proof of concept, I created a whole Pygame game, TMWOTY2, which is choreographed by 6 Agents as their own processes, which runs at a solid 60 frames per second. To start or open TMWOTY2 in the Monipulator, run the 'tmwoty2' or 'edittmwoty2' script. Your focus should on the 'workbench' folder, which contains all Agents and their respective Python modules; the 'Pytrithon' folder is just the backstage where the magic happens.\n\n## What Is New\n\nSince my last post the whole system now handles Agents, Monipulators, and Nexi terminating from the network. Bookkeeping is performed, cleansing the internal structures handling all process types, making the prototype more resilient. The 'chatserver' and 'chat' Agents now show a list of Agents currently connected. This is enabled through the new 'Event' Transition, which pushes Nexus Events to all listening Agents.\n\nSince my penultimate post I have added a distributed Yahtzee game which you should try out. In order to setup a server on a reachable machine and connect other machines, you need to do the following:\nOn the machine meant to be the server, run 'python nexus yahtzeeserver' first. Then on the machines meant to be the clients through which users play, run 'python nexus -x \\ yahtzee'. The clients probe the interconnected Nexi for a server and start with a lobby mask where you can select your name and start a game with all players signed up.\n\n## GitHub Link\n\nhttps://github.com/JochenSimon/pytrithon\n\n-------\n\nThis is the fifth post about Pytrithon on Reddit. There is a plethora of example Agents to view and run included in the repository.\nPlease check it out and send feedback to the E-Mail address stated in the Monipulator About blurb.\nI plan on putting Pytrithon onto the next level soon. Be sure to check for new happenings.", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_opul15z", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "Introduction \n\n
I have already introduced Pytrithon three times on Reddit.\nSee:
\n\n
https://www.reddit.com/r/Python/comments/1q8dwsm/pytrithon_v119_graphical_petri_net_inspired_agent/ \nhttps://www.reddit.com/r/Python/comments/1nr3qvm/pytrithon_graphical_petrinet_inspired_agent/ \nhttps://www.reddit.com/r/Python/comments/1mx9w5r/graphical_petrinet_inspired_agent_oriented/
\n\n
What My Project Does \n\n
Pytrithon is a graphical Petri net inspired agent oriented programming language based on Python.\nIt allows writing code as a two dimensional graph of interconnected elements and separates data as Places and code as Transitions. Inter Agent communication and GUI widgets are first class components of the language. Through the Monipulator, Agents can be monitored and manipulated.
\n\n
Target Audience \n\n
The target audience is both experienced and novice programmers who want to try something new.
\n\n
Why I Built It \n\n
I realized the power of Petri net inspired programming and the joy of having a more expressive way to specify control flow.
\n\n
Comparison \n\n
There are no other visual programming languages which embed actual code into their graphs.
\n\n
How To Explore \n\n
To run all included example Agents you need at least Python 3.10 installed. To install all dependencies, run the 'install' script. Then you can start up a Nexus with a Monipulator by running the 'pytrithon' script, where you can start Agents through opening them with 'crtl-o' twice and hitting the 'Open Agent' button. You can also directly specify which Agents to run through the command line by starting a Nexus, Monipulator, and Agents in one single command: 'python nexus -m <agent1> <agent2>'.
\n\n
Recommended example Agents to run are: 'basic', 'prodcons', 'address', 'kata', 'calculator', 'kniffel', 'guess', 'yahtzeeserver' + multiple 'yahtzee', 'pokerserver' + multiple 'poker', 'chatserver' + multiple 'chat', 'image', 'jobapplic', and 'nethods'. As a proof of concept, I created a whole Pygame game, TMWOTY2, which is choreographed by 6 Agents as their own processes, which runs at a solid 60 frames per second. To start or open TMWOTY2 in the Monipulator, run the 'tmwoty2' or 'edittmwoty2' script. Your focus should on the 'workbench' folder, which contains all Agents and their respective Python modules; the 'Pytrithon' folder is just the backstage where the magic happens.
\n\n
What Is New \n\n
Since my last post the whole system now handles Agents, Monipulators, and Nexi terminating from the network. Bookkeeping is performed, cleansing the internal structures handling all process types, making the prototype more resilient. The 'chatserver' and 'chat' Agents now show a list of Agents currently connected. This is enabled through the new 'Event' Transition, which pushes Nexus Events to all listening Agents.
\n\n
Since my penultimate post I have added a distributed Yahtzee game which you should try out. In order to setup a server on a reachable machine and connect other machines, you need to do the following:\nOn the machine meant to be the server, run 'python nexus yahtzeeserver' first. Then on the machines meant to be the clients through which users play, run 'python nexus -x <serveraddress> yahtzee'. The clients probe the interconnected Nexi for a server and start with a lobby mask where you can select your name and start a game with all players signed up.
\n\n
GitHub Link \n\n
https://github.com/JochenSimon/pytrithon
\n\n
\n\n
This is the fifth post about Pytrithon on Reddit. There is a plethora of example Agents to view and run included in the repository.\nPlease check it out and send feedback to the E-Mail address stated in the Monipulator About blurb.\nI plan on putting Pytrithon onto the next level soon. Be sure to check for new happenings.
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/opul15z/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780636699.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 1}}, {"kind": "t1", "data": {"subreddit_id": "t5_2qh0y", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "awarders": [], "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "Python", "author_flair_template_id": null, "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "opuw6vu", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "AdHot6282", "can_mod_post": false, "created_utc": 1780642079.0, "send_replies": true, "parent_id": "t3_1tws1w7", "score": 1, "author_fullname": "t2_p6h1483kx", "approved_by": null, "mod_note": null, "all_awardings": [], "collapsed": false, "body": "LocalClicky is a menubar app that lets you control your Mac with your voice, completely offline. \n \n \nSay \"Computer\" to start a session. It stays active - chain commands without repeating the wake word. Say \"bye\" to end. It auto-stops recording when you stop talking (webrtcvad), so there's no fixed timeout. \n \n \nWhat it can do: click things on your screen by name, open/quit apps, control Spotify and volume, create reminders from natural language, run shell commands, inject JS into Chrome. Vision is on-demand \u2014 the model calls look\\_at\\_screen itself when it needs to see something.\n\nOne thing that pushed me to build this: I noticed most people don't think twice before enabling cloud based AI assistants on their machines. But these tools are taking full screenshots of your screen, your code, your emails, your Figma files, your bank statements, your personal moment and sending them to a server. I don't like that at all. LocalClicky's vision model runs locally; screenshots never leave your machine.\n\n \nStack: Python, Whisper.cpp, Ollama (qwen3:8b + gemma4:e4b), webrtcvad, PyAutoGUI, rumps. \n \n \nNothing leaves your machine. MIT licensed, open source. \n \n \nGitHub: [https://github.com/dikshantrajput/LocalClicky](https://github.com/dikshantrajput/LocalClicky) \nDemo: [https://www.youtube.com/watch?v=i8QpFR6nEY4](https://www.youtube.com/watch?v=i8QpFR6nEY4)", "edited": false, "top_awarded_type": null, "author_flair_css_class": null, "name": "t1_opuw6vu", "is_submitter": false, "downs": 0, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "LocalClicky is a menubar app that lets you control your Mac with your voice, completely offline.
\n\n
Say "Computer" to start a session. It stays active - chain commands without repeating the wake word. Say "bye" to end. It auto-stops recording when you stop talking (webrtcvad), so there's no fixed timeout.
\n\n
What it can do: click things on your screen by name, open/quit apps, control Spotify and volume, create reminders from natural language, run shell commands, inject JS into Chrome. Vision is on-demand \u2014 the model calls look_at_screen itself when it needs to see something.
\n\n
One thing that pushed me to build this: I noticed most people don't think twice before enabling cloud based AI assistants on their machines. But these tools are taking full screenshots of your screen, your code, your emails, your Figma files, your bank statements, your personal moment and sending them to a server. I don't like that at all. LocalClicky's vision model runs locally; screenshots never leave your machine.
\n\n
Stack: Python, Whisper.cpp, Ollama (qwen3:8b + gemma4:e4b), webrtcvad, PyAutoGUI, rumps.
\n\n
Nothing leaves your machine. MIT licensed, open source.
\n\n
GitHub: https://github.com/dikshantrajput/LocalClicky \nDemo: https://www.youtube.com/watch?v=i8QpFR6nEY4
\n
", "removal_reason": null, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": false, "gildings": {}, "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/Python/comments/1tws1w7/showcase_thread/opuw6vu/", "subreddit_type": "public", "locked": false, "report_reasons": null, "created": 1780642079.0, "author_flair_text": null, "treatment_tags": [], "link_id": "t3_1tws1w7", "subreddit_name_prefixed": "r/Python", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "num_reports": null, "ups": 1}}, {"kind": "more", "data": {"count": 193, "name": "t1_opwgwoi", "id": "opwgwoi", "parent_id": "t3_1tws1w7", "depth": 0, "children": ["opwgwoi", "opwtnqv", "opyqlyr", "oq3bs7r", "opzzs23", "oq1whth", "oq1v3y9", "oq8v60l", "oq8i924", "oq6mjf1", "oq2jzij", "oqg5gfa", "oq3ycth", "oqonmb5", "oqh241n", "oqlbezo", "oqfz79y", "oqeqodc", "oqlraf9", "oqn45re", "oq9f2ol", "oq351b0", "oq82llc", "oqm1yhi", "oqgccc2", "oqm2wyz", "oqmhoy5", "orfatfq", "os57st2", "oqq7go0", "os3iesd", "oqz1f8j", "oqv3m6f", "ordqhi0", "orggusr", "oqg3sxh", "os8fu61", "oqmux4u", "oqsiroa", "orj2t6c", "osdrfxd", "orcrbot", "orl3amm", "os1bujj", "os1bslg", "oq88l8r", "oqa89aj", "oqm3sah", "or1h1i1", "ornjldf", "os1bjka", "ot45yd0", "otcaqvg", "oskwuov", "otm5dr8", "orqkqzh", "oskcn73", "ou05zjc", "osdrrsv", "or2o0hc", "or2wz4i", "ose4mwn", "otik76y", "ou3jtfi", "orayagm", "or48pdm", "or4mymi", "osy66rd", "ouvq47j", "ot2dwjl", "orstatp", "orswnog", "oqgxkc5", "otejpd7", "otzyfkb", "otye4hz", "oqqvklr", "or4fva1", "oryyg7m", "os6vy45", "ormojhi", "osk8x4i", "oslgjd1", "oswij3d", "ounqnaf", "oswi1ha", "os0emx6", "osay6i5", "ou4qsxk", "osjsmlw", "oujmvmv", "ouzdhfb", "orhhvzi", "oqa9y6c", "or780cu", "orou62e", "orz37b3", "oruz6fi", "otay7sy", "otptzzv", "os6yr4u", "os1bvxu", "oth1uto", "ova1vbj", "ou6v9t9", "otcgma0", "ouhufmq", "ouktsbw", "ouus4d8", "ou4vz0t", "otrro6f", "ouj22j2", "oukezil", "otnkxz0", "ouwrnax", "ouo4rtt", "ouaa4g9", "osqbpid", "ostr6h1", "ot2qz8l", "ory8egy", "otikk0d", "oul28wi", "otibuj8", "otx91u2", "oucx7sc", "ouzhogi", "ov32np3", "oubafil", "orsrjeo", "orlwbju", "orm9g8l", "os1o62h", "os6k48f", "ouq2dja", "otx9qa4", "ouz7ekk", "opskei3", "ovf2cnm", "otr48db", "oupjpg4", "oukhre1", "ovhhem5", "ospxx2g", "orrxvux", "orcjorb", "ouxhgeu", "ouzgjhg", "ouagbv9", "ouiiv9w", "oukwgbi", "ov4z1m4", "oqzmh06", "oqs64e4", "orrzi6i", "ork3l7k", "oshwp5h", "otc4w9v", "otl0juz", "ovajat5", "or9m71i", "osg9pq0", "oube17h", "ouu84r1", "ouvs7p4", "ovc3kpi", "oup0ukf", "oprodsc", "oprsgod", "ot4i9w4", "otcj6nf", "ot44xc7", "os6ph8i", "oslz59n", "osln80c", "ouqxogi", "ou59piq"]}}], "before": null}}]
\ No newline at end of file
diff --git a/surfsense_backend/tests/unit/platforms/reddit/test_fetch_resilience.py b/surfsense_backend/tests/unit/platforms/reddit/test_fetch_resilience.py
new file mode 100644
index 000000000..610855f5c
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/reddit/test_fetch_resilience.py
@@ -0,0 +1,268 @@
+"""Offline resilience tests for the Reddit fetch seam and fan-out worker pool.
+
+No network. Fake sessions drive the ``loid`` warm-up + rotate-on-block + backoff
+paths deterministically (in live runs the first IP warms and returns 200s, so
+these branches rarely fire). Mirrors the youtube sibling's
+``test_fetch_resilience.py`` shape, extended with a fake warm-up.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import AsyncIterator
+
+from app.proprietary.platforms.reddit import fetch, scraper
+from app.proprietary.platforms.reddit.fetch import (
+ RedditAccessBlockedError,
+ _current_session,
+ fetch_json,
+)
+
+_LISTING = {"kind": "Listing", "data": {"children": [], "after": None}}
+
+
+class _FakePage:
+ def __init__(self, status: int, *, cookies: dict | None = None, payload=None):
+ self.status = status
+ self.cookies = cookies or {}
+ self._payload = payload if payload is not None else _LISTING
+
+ def json(self):
+ return self._payload
+
+ @property
+ def body(self) -> str:
+ return json.dumps(self._payload)
+
+
+class _FakeSession:
+ """One 'IP': warm-up mints loid per flags; ``.json`` GETs return ``status``."""
+
+ def __init__(
+ self,
+ status: int = 200,
+ *,
+ shreddit_loid: bool = True,
+ old_loid: bool = False,
+ payload=None,
+ ) -> None:
+ self.status = status
+ self.shreddit_loid = shreddit_loid
+ self.old_loid = old_loid
+ self.payload = payload
+ self.json_calls = 0
+ self.warm_calls = 0
+
+ async def get(self, url, headers=None, cookies=None):
+ if "svc/shreddit" in url:
+ self.warm_calls += 1
+ ck = {"loid": "x", "session_tracker": "y"} if self.shreddit_loid else {}
+ return _FakePage(200, cookies=ck)
+ if "old.reddit.com" in url:
+ self.warm_calls += 1
+ return _FakePage(200, cookies={"loid": "x"} if self.old_loid else {})
+ self.json_calls += 1
+ return _FakePage(self.status, payload=self.payload)
+
+
+class _FakeHolder:
+ """Holder whose ``rotate()`` advances to the next fake session (a new IP)."""
+
+ def __init__(self, sessions: list[_FakeSession]) -> None:
+ self._sessions = sessions
+ self.session = sessions[0]
+ self.rotations = 0
+ self.warmed = False
+
+ async def rotate(self):
+ self.rotations += 1
+ self.session = self._sessions[min(self.rotations, len(self._sessions) - 1)]
+ self.warmed = False # loid binds to the IP: re-warm on the fresh one
+ return self.session
+
+ async def pace(self) -> None:
+ return None
+
+ async def close(self) -> None:
+ return None
+
+
+def _no_sleep(monkeypatch) -> None:
+ async def _noop(_seconds):
+ return None
+
+ monkeypatch.setattr(fetch.asyncio, "sleep", _noop)
+
+
+async def test_warms_then_returns_json():
+ # shreddit is tried first and mints loid -> a single warm call.
+ holder = _FakeHolder([_FakeSession(200, shreddit_loid=True)])
+ token = _current_session.set(holder)
+ try:
+ result = await fetch_json("r/python/hot")
+ finally:
+ _current_session.reset(token)
+ assert result == _LISTING
+ assert holder.rotations == 0
+ assert holder.session.warm_calls == 1 # warmed exactly once
+
+
+async def test_warm_falls_back_to_old_reddit():
+ # shreddit doesn't mint loid, old.reddit does -> still warms on the same IP.
+ holder = _FakeHolder([_FakeSession(200, shreddit_loid=False, old_loid=True)])
+ token = _current_session.set(holder)
+ try:
+ result = await fetch_json("r/python/hot")
+ finally:
+ _current_session.reset(token)
+ assert result == _LISTING
+ assert holder.rotations == 0
+
+
+async def test_rotates_when_warm_fails_then_succeeds():
+ # IP0 can't mint loid at all -> rotate; IP1 warms fine.
+ holder = _FakeHolder(
+ [
+ _FakeSession(200, shreddit_loid=False, old_loid=False),
+ _FakeSession(200, shreddit_loid=True),
+ ]
+ )
+ token = _current_session.set(holder)
+ try:
+ result = await fetch_json("r/python/hot")
+ finally:
+ _current_session.reset(token)
+ assert result == _LISTING
+ assert holder.rotations == 1
+
+
+async def test_raises_when_no_ip_can_warm():
+ holder = _FakeHolder(
+ [
+ _FakeSession(200, shreddit_loid=False, old_loid=False)
+ for _ in range(fetch._MAX_ROTATIONS + 1)
+ ]
+ )
+ token = _current_session.set(holder)
+ try:
+ raised = False
+ try:
+ await fetch_json("r/python/hot")
+ except RedditAccessBlockedError:
+ raised = True
+ finally:
+ _current_session.reset(token)
+ assert raised
+ assert holder.rotations == fetch._MAX_ROTATIONS
+
+
+async def test_rotates_and_rewarms_on_403():
+ holder = _FakeHolder([_FakeSession(403), _FakeSession(200, old_loid=True)])
+ token = _current_session.set(holder)
+ try:
+ result = await fetch_json("r/python/hot")
+ finally:
+ _current_session.reset(token)
+ assert result == _LISTING
+ assert holder.rotations == 1
+ assert holder.session.warm_calls == 1 # re-warmed on the fresh IP
+
+
+async def test_404_returns_none_without_rotating():
+ holder = _FakeHolder([_FakeSession(404), _FakeSession(200)])
+ token = _current_session.set(holder)
+ try:
+ result = await fetch_json("r/python/comments/missing")
+ finally:
+ _current_session.reset(token)
+ assert result is None
+ assert holder.rotations == 0
+
+
+async def test_429_backs_off_without_rotating(monkeypatch):
+ _no_sleep(monkeypatch)
+ # Same IP: 429 first, then a healthy 200 on retry (no rotation).
+ session = _FakeSession(429)
+
+ async def _get(url, headers=None, cookies=None):
+ if "svc/shreddit" in url or "old.reddit.com" in url:
+ session.warm_calls += 1
+ return _FakePage(200, cookies={"loid": "x"})
+ session.json_calls += 1
+ return _FakePage(429 if session.json_calls == 1 else 200)
+
+ session.get = _get # type: ignore[method-assign]
+ holder = _FakeHolder([session])
+ token = _current_session.set(holder)
+ try:
+ result = await fetch_json("r/python/hot")
+ finally:
+ _current_session.reset(token)
+ assert result == _LISTING
+ assert holder.rotations == 0
+
+
+async def test_persistent_403_raises_blocked(monkeypatch):
+ _no_sleep(monkeypatch)
+ holder = _FakeHolder([_FakeSession(403) for _ in range(fetch._MAX_ROTATIONS + 1)])
+ token = _current_session.set(holder)
+ try:
+ raised = False
+ try:
+ await fetch_json("r/python/hot")
+ except RedditAccessBlockedError:
+ raised = True
+ finally:
+ _current_session.reset(token)
+ assert raised
+ assert holder.rotations == fetch._MAX_ROTATIONS
+
+
+class _TrackingHolder:
+ """Fake fan-out session holder that records whether it was closed."""
+
+ def __init__(self) -> None:
+ self.closed = False
+
+ async def close(self) -> None:
+ self.closed = True
+
+
+async def test_fan_out_closes_all_sessions_on_early_stop(monkeypatch):
+ holders: list[_TrackingHolder] = []
+
+ async def _fake_open():
+ h = _TrackingHolder()
+ holders.append(h)
+ return h
+
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def _fake_bind(_holder):
+ yield _holder
+
+ monkeypatch.setattr(scraper, "open_proxy_holder", _fake_open)
+ monkeypatch.setattr(scraper, "bind_proxy_holder", _fake_bind)
+
+ async def _job(n: int) -> AsyncIterator[dict]:
+ for i in range(5):
+ yield {"job": n, "i": i}
+
+ jobs = [_job(n) for n in range(20)]
+ gen = scraper.fan_out(jobs, concurrency=4)
+ collected = []
+ async for item in gen:
+ collected.append(item)
+ if len(collected) >= 3:
+ break
+ await gen.aclose()
+
+ assert len(collected) >= 3
+ assert holders, "workers should have opened sessions"
+ assert all(h.closed for h in holders), "a worker leaked its proxy session"
+
+
+async def test_fan_out_empty_jobs_is_noop():
+ out = [x async for x in scraper.fan_out([])]
+ assert out == []
diff --git a/surfsense_backend/tests/unit/platforms/reddit/test_parsers.py b/surfsense_backend/tests/unit/platforms/reddit/test_parsers.py
new file mode 100644
index 000000000..77f1312a4
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/reddit/test_parsers.py
@@ -0,0 +1,182 @@
+"""Offline parser tests for the Reddit scraper.
+
+Two layers:
+- Synthetic, deterministic checks of the JSON->item mapping (hand-built minimal
+ "things" — no live Reddit shapes), which run always.
+- Fixture-pinned checks against real ``.json`` captured by
+ ``scripts/e2e_reddit_scraper.py`` into ``fixtures/``; these ``skip`` when the
+ fixtures are absent (mirrors the youtube sibling). Fill in richer assertions
+ against the captured shapes during implementation.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from app.proprietary.platforms.reddit.parsers import (
+ after,
+ children,
+ flatten_comments,
+ parse_comment,
+ parse_community,
+ parse_post,
+)
+
+_FIXTURE_DIR = Path(__file__).parent / "fixtures"
+
+
+# --- synthetic mapping (always runs) ---------------------------------------
+
+
+def test_parse_post_maps_core_fields():
+ thing = {
+ "kind": "t3",
+ "data": {
+ "name": "t3_x",
+ "id": "x",
+ "author": "alice",
+ "author_fullname": "t2_a",
+ "title": "Hello",
+ "selftext": "body text",
+ "permalink": "/r/py/comments/x/hello/",
+ "subreddit": "py",
+ "subreddit_name_prefixed": "r/py",
+ "num_comments": 3,
+ "score": 42,
+ "upvote_ratio": 0.97,
+ "over_18": False,
+ "is_self": True,
+ "created_utc": 1_700_000_000,
+ "thumbnail": "self",
+ },
+ }
+ item = parse_post(thing)
+ assert item["dataType"] == "post"
+ assert item["id"] == "t3_x"
+ assert item["parsedId"] == "x"
+ assert item["url"] == "https://www.reddit.com/r/py/comments/x/hello/"
+ assert item["upVotes"] == 42
+ assert item["numberOfComments"] == 3
+ assert item["thumbnailUrl"] is None # 'self' sentinel is not a URL
+ assert item["createdAt"] == "2023-11-14T22:13:20.000Z"
+
+
+def test_parse_comment_strips_link_prefix():
+ thing = {
+ "kind": "t1",
+ "data": {
+ "name": "t1_c",
+ "id": "c",
+ "body": "a comment",
+ "link_id": "t3_x",
+ "parent_id": "t3_x",
+ "created_utc": 1_700_000_000,
+ },
+ }
+ item = parse_comment(thing)
+ assert item["dataType"] == "comment"
+ assert item["postId"] == "x" # t3_ prefix stripped
+ assert item["parentId"] == "t3_x"
+
+
+def test_flatten_comments_counts_replies_and_stops_at_more():
+ tree = [
+ {
+ "kind": "t1",
+ "data": {
+ "name": "t1_1",
+ "id": "1",
+ "body": "top",
+ "created_utc": 1,
+ "replies": {
+ "kind": "Listing",
+ "data": {
+ "children": [
+ {
+ "kind": "t1",
+ "data": {
+ "name": "t1_2",
+ "id": "2",
+ "body": "reply",
+ "replies": "",
+ },
+ },
+ {"kind": "more", "data": {}}, # stub -> ignored
+ ]
+ },
+ },
+ },
+ }
+ ]
+ flat = flatten_comments(tree, max_comments=10)
+ assert len(flat) == 2 # the 'more' stub is skipped
+ assert flat[0]["numberOfReplies"] == 1
+ assert [c["depth"] for c in flat] == [0, 1]
+
+
+def test_flatten_comments_honors_max():
+ tree = [
+ {
+ "kind": "t1",
+ "data": {"name": f"t1_{i}", "id": str(i), "body": "x", "replies": ""},
+ }
+ for i in range(5)
+ ]
+ assert len(flatten_comments(tree, max_comments=2)) == 2
+
+
+def test_children_and_after():
+ listing = {"kind": "Listing", "data": {"children": [1, 2, 3], "after": "t3_next"}}
+ assert children(listing) == [1, 2, 3]
+ assert after(listing) == "t3_next"
+ assert children({}) == []
+ assert after({"data": {"after": None}}) is None
+
+
+def test_parse_community_maps_members():
+ thing = {
+ "kind": "t5",
+ "data": {
+ "name": "t5_s",
+ "id": "s",
+ "display_name": "py",
+ "display_name_prefixed": "r/py",
+ "subscribers": 1234,
+ "url": "/r/py/",
+ },
+ }
+ item = parse_community(thing)
+ assert item["dataType"] == "community"
+ assert item["numberOfMembers"] == 1234
+ assert item["url"] == "https://www.reddit.com/r/py/"
+
+
+# --- fixture-pinned (skips until e2e captures real .json) ------------------
+
+
+def _load(name: str):
+ path = _FIXTURE_DIR / name
+ if not path.exists():
+ pytest.skip(f"fixture {name} not captured yet (run e2e_reddit_scraper.py)")
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def test_parse_captured_post_fixture_if_present():
+ data = _load("sample_post.json")
+ # sample_post.json is the [postListing, commentsListing] .json shape.
+ post_children = children(data[0]) if isinstance(data, list) else []
+ assert post_children, "captured post fixture has no post child"
+ item = parse_post(post_children[0])
+ assert item["dataType"] == "post"
+ assert item["id"]
+
+
+def test_parse_captured_listing_fixture_if_present():
+ listing = _load("sample_listing.json")
+ kids = children(listing)
+ assert kids, "captured listing fixture is empty"
+ posts = [parse_post(c) for c in kids if c.get("kind") == "t3"]
+ assert posts, "no t3 posts in captured listing"
diff --git a/surfsense_backend/tests/unit/platforms/reddit/test_search_budget.py b/surfsense_backend/tests/unit/platforms/reddit/test_search_budget.py
new file mode 100644
index 000000000..ce58199c8
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/reddit/test_search_budget.py
@@ -0,0 +1,69 @@
+"""Offline tests for multi-search budgeting in ``iter_reddit``.
+
+No network: ``_search_flow`` is faked. Asserts the maxItems budget is
+fair-shared across concurrent searches (a noisy query can't starve the rest)
+and that the same post surfacing via several queries is emitted once.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+
+from app.proprietary.platforms.reddit import scraper
+from app.proprietary.platforms.reddit.schemas import RedditScrapeInput
+
+
+def _fake_search_flow(results_by_query: dict[str, list[str]]):
+ """Fake flow yielding post dicts (id per entry), honoring ``max_items``."""
+ calls: dict[str, int] = {}
+
+ def flow(
+ query: str,
+ *,
+ input_model: RedditScrapeInput,
+ subreddit: str | None = None,
+ max_items: int | None = None,
+ ) -> AsyncIterator[dict]:
+ cap = input_model.maxItems if max_items is None else max_items
+ calls[query] = cap
+
+ async def gen() -> AsyncIterator[dict]:
+ for pid in results_by_query.get(query, [])[:cap]:
+ yield {"dataType": "post", "id": pid, "title": pid}
+
+ return gen()
+
+ return flow, calls
+
+
+async def test_budget_is_fair_shared_across_searches(monkeypatch):
+ # One noisy query with 100 hits must not starve the two precise ones.
+ data = {
+ "noisy": [f"n{i}" for i in range(100)],
+ "precise_a": ["a1", "a2", "a3"],
+ "precise_b": ["b1", "b2"],
+ }
+ flow, calls = _fake_search_flow(data)
+ monkeypatch.setattr(scraper, "_search_flow", flow)
+
+ model = RedditScrapeInput(searches=list(data), maxItems=30)
+ items = await scraper.scrape_reddit(model, limit=30)
+
+ ids = {i["id"] for i in items}
+ # Every precise result made it in; noisy filled only its ceil(30/3)=10 share.
+ assert {"a1", "a2", "a3", "b1", "b2"} <= ids
+ assert sum(1 for i in ids if i.startswith("n")) == 10
+ assert all(cap == 10 for cap in calls.values())
+
+
+async def test_duplicate_posts_across_searches_emit_once(monkeypatch):
+ data = {"q1": ["dup", "x1"], "q2": ["dup", "x2"]}
+ flow, _ = _fake_search_flow(data)
+ monkeypatch.setattr(scraper, "_search_flow", flow)
+
+ model = RedditScrapeInput(searches=["q1", "q2"], maxItems=10)
+ items = await scraper.scrape_reddit(model, limit=10)
+
+ ids = [i["id"] for i in items]
+ assert ids.count("dup") == 1
+ assert {"x1", "x2"} <= set(ids)
diff --git a/surfsense_backend/tests/unit/platforms/reddit/test_skeleton.py b/surfsense_backend/tests/unit/platforms/reddit/test_skeleton.py
new file mode 100644
index 000000000..bec5e5127
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/reddit/test_skeleton.py
@@ -0,0 +1,73 @@
+"""Offline schema + URL-resolver tests for the Reddit scraper.
+
+Deterministic (no network, no live Reddit shapes): asserts the anonymous-only
+input surface, the flat item serialization contract, and URL classification.
+"""
+
+from __future__ import annotations
+
+from app.proprietary.platforms.reddit.schemas import RedditItem, RedditScrapeInput
+from app.proprietary.platforms.reddit.url_resolver import resolve_url
+
+
+def test_input_has_no_auth_fields():
+ # Anonymous-only: no auth-shaped field may exist on the input surface.
+ forbidden = {"username", "password", "token", "login", "auth", "credentials"}
+ assert forbidden.isdisjoint(RedditScrapeInput.model_fields)
+
+
+def test_input_defaults():
+ model = RedditScrapeInput()
+ assert model.sort == "new"
+ assert model.includeNSFW is True
+ assert model.maxItems == 10
+ assert model.startUrls == []
+ assert model.searches == []
+
+
+def test_input_allows_extra_inert_fields():
+ # extra="allow": unknown inert fields are accepted, not rejected.
+ model = RedditScrapeInput(debugMode=True, proxy={"provider": "custom"})
+ assert model.model_dump().get("debugMode") is True
+
+
+def test_item_to_output_keeps_none_keys():
+ out = RedditItem(dataType="post", id="t3_x").to_output()
+ assert out["dataType"] == "post"
+ assert out["id"] == "t3_x"
+ assert "numberOfComments" in out # unsourced fields still present (None)
+ assert out["numberOfComments"] is None
+
+
+def test_resolve_post():
+ r = resolve_url("https://www.reddit.com/r/python/comments/abc123/some_title/")
+ assert r is not None
+ assert r.kind == "post"
+ assert r.value == "abc123"
+ assert r.subreddit == "python"
+
+
+def test_resolve_subreddit_with_and_without_sort():
+ bare = resolve_url("https://www.reddit.com/r/python")
+ assert bare is not None and bare.kind == "subreddit" and bare.sort is None
+ sorted_ = resolve_url("https://www.reddit.com/r/python/top")
+ assert sorted_ is not None and sorted_.sort == "top"
+
+
+def test_resolve_user_tabs():
+ overview = resolve_url("https://www.reddit.com/user/spez")
+ assert overview is not None and overview.kind == "user" and overview.content is None
+ comments = resolve_url("https://www.reddit.com/u/spez/comments")
+ assert comments is not None and comments.content == "comments"
+
+
+def test_resolve_search_global_and_in_sub():
+ global_ = resolve_url("https://www.reddit.com/search/?q=hello")
+ assert global_ is not None and global_.kind == "search" and global_.value == "hello"
+ in_sub = resolve_url("https://www.reddit.com/r/python/search/?q=async")
+ assert in_sub is not None and in_sub.subreddit == "python"
+
+
+def test_resolve_rejects_non_reddit_host():
+ assert resolve_url("https://example.com/r/python") is None
+ assert resolve_url("https://notreddit.com/user/x") is None
diff --git a/surfsense_backend/tests/unit/platforms/youtube/__init__.py b/surfsense_backend/tests/unit/platforms/youtube/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/platforms/youtube/test_fetch_resilience.py b/surfsense_backend/tests/unit/platforms/youtube/test_fetch_resilience.py
new file mode 100644
index 000000000..9f20271be
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/youtube/test_fetch_resilience.py
@@ -0,0 +1,201 @@
+"""Offline resilience tests for the fetch seam and fan-out worker pool.
+
+No network. Stubs the proxy session so the rotate-on-block path (which never
+fires in practice because live runs return 200s) is exercised deterministically,
+and asserts the worker pool closes every session when the consumer stops early.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+
+from app.proprietary.platforms.youtube import innertube, scraper
+from app.proprietary.platforms.youtube.innertube import (
+ INNERTUBE_SEARCH_URL,
+ _current_session,
+ fetch_html,
+ post_innertube,
+)
+
+
+class _FakePage:
+ def __init__(self, status: int) -> None:
+ self.status = status
+
+ def json(self) -> dict:
+ return {"status": self.status}
+
+ @property
+ def html_content(self) -> str:
+ return "ok"
+
+
+class _FakeSession:
+ """One 'IP': returns ``status`` for every request, or raises if ``exc``."""
+
+ def __init__(self, status: int = 200, *, exc: bool = False) -> None:
+ self.status = status
+ self.exc = exc
+ self.calls = 0
+
+ async def post(self, url, json=None):
+ self.calls += 1
+ if self.exc:
+ raise ConnectionError("boom")
+ return _FakePage(self.status)
+
+ async def get(self, url, headers=None, cookies=None):
+ self.calls += 1
+ if self.exc:
+ raise ConnectionError("boom")
+ return _FakePage(self.status)
+
+
+class _FakeHolder:
+ """Holder whose ``rotate()`` advances to the next fake session (a new IP)."""
+
+ def __init__(self, sessions: list[_FakeSession]) -> None:
+ self._sessions = sessions
+ self.session = sessions[0]
+ self.rotations = 0
+
+ async def rotate(self):
+ self.rotations += 1
+ self.session = self._sessions[min(self.rotations, len(self._sessions) - 1)]
+ return self.session
+
+
+def _payload() -> dict:
+ return {"context": {}}
+
+
+async def test_post_rotates_on_429_then_succeeds():
+ holder = _FakeHolder([_FakeSession(429), _FakeSession(200)])
+ token = _current_session.set(holder)
+ try:
+ result = await post_innertube(INNERTUBE_SEARCH_URL, _payload())
+ finally:
+ _current_session.reset(token)
+ assert result == {"status": 200}
+ assert holder.rotations == 1 # rotated exactly once to the healthy IP
+
+
+async def test_post_rotates_on_connection_error_then_succeeds():
+ holder = _FakeHolder([_FakeSession(exc=True), _FakeSession(200)])
+ token = _current_session.set(holder)
+ try:
+ result = await post_innertube(INNERTUBE_SEARCH_URL, _payload())
+ finally:
+ _current_session.reset(token)
+ assert result == {"status": 200}
+ assert holder.rotations == 1
+
+
+async def test_post_gives_up_after_max_rotations():
+ # Every IP is blocked -> rotate up to the cap, then return None.
+ holder = _FakeHolder(
+ [_FakeSession(429) for _ in range(innertube._MAX_ROTATIONS + 1)]
+ )
+ token = _current_session.set(holder)
+ try:
+ result = await post_innertube(INNERTUBE_SEARCH_URL, _payload())
+ finally:
+ _current_session.reset(token)
+ assert result is None
+ assert holder.rotations == innertube._MAX_ROTATIONS
+
+
+async def test_post_does_not_rotate_on_non_block_status():
+ # 404 is not a block: fail fast, no wasted IP rotations.
+ holder = _FakeHolder([_FakeSession(404), _FakeSession(200)])
+ token = _current_session.set(holder)
+ try:
+ result = await post_innertube(INNERTUBE_SEARCH_URL, _payload())
+ finally:
+ _current_session.reset(token)
+ assert result is None
+ assert holder.rotations == 0
+
+
+async def test_fetch_html_rotates_then_succeeds():
+ holder = _FakeHolder([_FakeSession(403), _FakeSession(200)])
+ token = _current_session.set(holder)
+ try:
+ html = await fetch_html("https://www.youtube.com/watch?v=abc")
+ finally:
+ _current_session.reset(token)
+ assert html == "ok"
+ assert holder.rotations == 1
+
+
+async def test_fetch_html_falls_back_to_stealthy_when_all_blocked(monkeypatch):
+ called: dict[str, str] = {}
+
+ async def _fake_stealthy(url: str):
+ called["url"] = url
+ return "stealthy"
+
+ monkeypatch.setattr(innertube, "_fetch_html_stealthy", _fake_stealthy)
+ holder = _FakeHolder(
+ [_FakeSession(429) for _ in range(innertube._MAX_ROTATIONS + 1)]
+ )
+ token = _current_session.set(holder)
+ try:
+ html = await fetch_html("https://www.youtube.com/watch?v=zzz")
+ finally:
+ _current_session.reset(token)
+ assert html == "stealthy"
+ assert called["url"].endswith("v=zzz")
+
+
+class _TrackingHolder:
+ """Fake fan-out session holder that records whether it was closed."""
+
+ def __init__(self) -> None:
+ self.closed = False
+
+ async def close(self) -> None:
+ self.closed = True
+
+
+async def test_fan_out_closes_all_sessions_on_early_stop(monkeypatch):
+ holders: list[_TrackingHolder] = []
+
+ async def _fake_open():
+ h = _TrackingHolder()
+ holders.append(h)
+ return h
+
+ # No real session binding needed; jobs just yield.
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def _fake_bind(_holder):
+ yield _holder
+
+ monkeypatch.setattr(scraper, "open_proxy_holder", _fake_open)
+ monkeypatch.setattr(scraper, "bind_proxy_holder", _fake_bind)
+
+ async def _job(n: int) -> AsyncIterator[dict]:
+ for i in range(5):
+ yield {"job": n, "i": i}
+
+ jobs = [_job(n) for n in range(20)]
+
+ gen = scraper.fan_out(jobs, concurrency=4)
+ collected = []
+ async for item in gen:
+ collected.append(item)
+ if len(collected) >= 3: # consumer stops early (like a limit)
+ break
+ await gen.aclose() # deterministically run fan_out's teardown
+
+ assert len(collected) >= 3
+ assert holders, "workers should have opened sessions"
+ # Every opened session must be closed after the generator is torn down.
+ assert all(h.closed for h in holders), "a worker leaked its proxy session"
+
+
+async def test_fan_out_empty_jobs_is_noop():
+ out = [x async for x in scraper.fan_out([])]
+ assert out == []
diff --git a/surfsense_backend/tests/unit/platforms/youtube/test_parsers.py b/surfsense_backend/tests/unit/platforms/youtube/test_parsers.py
new file mode 100644
index 000000000..55f591c63
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/youtube/test_parsers.py
@@ -0,0 +1,975 @@
+"""Offline unit tests for the YouTube scraper parsers.
+
+No network. Uses small hand-built raw ``ytInitialData`` / ``ytInitialPlayerResponse``
+shapes (mirroring real nesting) plus direct normalization cases — the smallest
+things that fail if the dict-walk or normalization logic breaks. If the e2e
+script has captured real fixtures into ``fixtures/``, they are exercised too.
+"""
+
+import base64
+import json
+from pathlib import Path
+
+import pytest
+
+from app.proprietary.platforms.youtube.parsers import (
+ channel_about_tokens,
+ comment_next_token,
+ comment_reply_tokens,
+ comment_section_token,
+ comment_sort_tokens,
+ dig,
+ find_all,
+ parse_channel_about,
+ parse_channel_metadata,
+ parse_channel_shorts,
+ parse_collaborators,
+ parse_comment_entities,
+ parse_count,
+ parse_date,
+ parse_description_links,
+ parse_location,
+ parse_playlist_video_ids,
+ parse_search_response,
+ parse_translation,
+ parse_video_page,
+ seconds_to_duration,
+)
+from app.proprietary.platforms.youtube.schemas import YouTubeScrapeInput
+from app.proprietary.platforms.youtube.search_filters import build_search_params
+from app.proprietary.platforms.youtube.url_resolver import resolve_url
+
+pytestmark = pytest.mark.unit
+
+_FIXTURE_DIR = Path(__file__).parent / "fixtures"
+
+
+# --- normalization -----------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "raw,expected",
+ [
+ ("451K views", 451_000),
+ ("1.2M", 1_200_000),
+ ("1,234", 1_234),
+ ("100", 100),
+ ("2.5B", 2_500_000_000),
+ (500, 500),
+ ("No views", None),
+ ("", None),
+ (None, None),
+ ],
+)
+def test_parse_count(raw, expected):
+ assert parse_count(raw) == expected
+
+
+def test_parse_date_prefers_publish_date():
+ mf = {"publishDate": "2024-08-27", "uploadDate": "2024-08-20"}
+ assert parse_date(mf) == "2024-08-27"
+ assert parse_date({"uploadDate": "2024-08-20"}) == "2024-08-20"
+ assert parse_date(None) is None
+
+
+def test_seconds_to_duration():
+ assert seconds_to_duration("207") == "00:03:27"
+ assert seconds_to_duration(3661) == "01:01:01"
+ assert seconds_to_duration(None) is None
+
+
+# --- traversal helpers -------------------------------------------------------
+
+
+def test_find_all_and_dig():
+ data = {"a": {"b": [{"k": 1}, {"k": 2}]}, "k": 3}
+ assert find_all(data, "k") == [1, 2, 3]
+ assert dig(data, "a", "b", 0, "k") == 1
+ assert dig(data, "a", "b", 5, "k") is None
+ assert dig(data, "missing", "x") is None
+
+
+# --- page parsing ------------------------------------------------------------
+
+
+def _video_html() -> str:
+ player = {
+ "videoDetails": {
+ "videoId": "abc123",
+ "title": "Test Title",
+ "lengthSeconds": "207",
+ "viewCount": "100",
+ "author": "TechReviewer",
+ "channelId": "UC123",
+ "shortDescription": "desc",
+ "keywords": ["#tech"],
+ "thumbnail": {
+ "thumbnails": [
+ {
+ "url": "https://i.ytimg.com/vi/abc123/hq.jpg",
+ "width": 480,
+ "height": 360,
+ }
+ ]
+ },
+ },
+ "microformat": {
+ "playerMicroformatRenderer": {
+ "publishDate": "2024-08-27",
+ "isFamilySafe": True,
+ }
+ },
+ "adPlacements": [{"adPlacementRenderer": {}}],
+ }
+ initial = {
+ "like": {"buttonViewModel": {"iconName": "LIKE", "title": "15K"}},
+ "comments": {
+ "itemSectionRenderer": {
+ "sectionIdentifier": "comment-item-section",
+ "contents": [{"continuationItemRenderer": {"trigger": "x"}}],
+ }
+ },
+ "ctx": {"contextualInfo": {"runs": [{"text": "1,250"}]}},
+ "chan": {"canonicalBaseUrl": "/@Apify"},
+ "subs": {"subscriberCountText": {"simpleText": "500K subscribers"}},
+ "badge": {"metadataBadgeRenderer": {"tooltip": "Verified"}},
+ }
+ return (
+ ""
+ )
+
+
+def test_parse_video_page():
+ result = parse_video_page(_video_html())
+ assert result is not None
+ assert result["id"] == "abc123"
+ assert result["url"] == "https://www.youtube.com/watch?v=abc123"
+ assert result["title"] == "Test Title"
+ assert result["viewCount"] == 100
+ assert result["duration"] == "00:03:27"
+ assert result["date"] == "2024-08-27"
+ assert result["thumbnailUrl"] == "https://i.ytimg.com/vi/abc123/hq.jpg"
+ assert result["hashtags"] == ["#tech"]
+ assert result["channelName"] == "TechReviewer"
+ assert result["channelId"] == "UC123"
+ assert result["likes"] == 15_000
+ assert result["commentsCount"] == 1_250
+ assert result["channelUrl"] == "https://www.youtube.com/@Apify"
+ assert result["channelUsername"] == "Apify"
+ assert result["numberOfSubscribers"] == 500_000
+ assert result["isChannelVerified"] is True
+ assert result["isMonetized"] is True # adPlacements present
+ assert result["isAgeRestricted"] is None # family safe, no age gate
+ assert result["commentsTurnedOff"] is False # section has a continuation
+
+
+def test_parse_video_page_comments_turned_off():
+ player = {"videoDetails": {"videoId": "x", "title": "t"}}
+ initial = {
+ "itemSectionRenderer": {
+ "sectionIdentifier": "comment-item-section",
+ "contents": [{"messageRenderer": {"text": {"simpleText": "off"}}}],
+ }
+ }
+ html = (
+ ""
+ )
+ result = parse_video_page(html)
+ assert result is not None
+ assert result["commentsTurnedOff"] is True
+ assert result["commentsCount"] is None
+
+
+def test_parse_video_page_age_restricted():
+ player = {
+ "videoDetails": {"videoId": "x", "title": "t"},
+ "playabilityStatus": {
+ "status": "LOGIN_REQUIRED",
+ "desktopLegacyAgeGateReason": 1,
+ },
+ }
+ html = ""
+ result = parse_video_page(html)
+ assert result is not None
+ assert result["isAgeRestricted"] is True
+ assert result["isMonetized"] is None # no ad slots → can't confirm
+
+
+def test_parse_video_page_members_only_and_paid():
+ player = {
+ "videoDetails": {"videoId": "m", "title": "t"},
+ "playabilityStatus": {"errorScreen": {"x": {"offerId": "sponsors_only_video"}}},
+ "paidContentOverlay": {
+ "paidContentOverlayRenderer": {"text": "Includes paid promotion"}
+ },
+ }
+ initial = {
+ "badges": [
+ {
+ "metadataBadgeRenderer": {
+ "style": "BADGE_STYLE_TYPE_MEMBERS_ONLY",
+ "label": "Members only",
+ }
+ }
+ ]
+ }
+ html = (
+ ""
+ )
+ result = parse_video_page(html)
+ assert result is not None
+ assert result["isMembersOnly"] is True
+ assert result["isPaidContent"] is True
+
+
+def test_parse_video_page_returns_none_without_player():
+ assert parse_video_page("no data here") is None
+
+
+def test_parse_channel_metadata():
+ initial = {
+ "metadata": {
+ "channelMetadataRenderer": {
+ "title": "Apify",
+ "externalId": "UCabc123",
+ "description": "We scrape the web.",
+ "vanityChannelUrl": "https://www.youtube.com/@Apify",
+ "avatar": {
+ "thumbnails": [
+ {"url": "https://a/avatar.jpg", "width": 88, "height": 88}
+ ]
+ },
+ }
+ },
+ "header": {
+ "pageHeaderViewModel": {
+ "banner": {
+ "imageBannerViewModel": {
+ "image": {
+ "sources": [
+ {
+ "url": "https://a/banner.jpg",
+ "width": 1060,
+ "height": 175,
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ }
+ meta = parse_channel_metadata(initial)
+ assert meta["channelName"] == "Apify"
+ assert meta["channelId"] == "UCabc123"
+ assert meta["channelDescription"] == "We scrape the web."
+ assert meta["channelUrl"] == "https://www.youtube.com/@Apify"
+ assert meta["channelAvatarUrl"] == "https://a/avatar.jpg"
+ assert meta["channelBannerUrl"] == "https://a/banner.jpg"
+ assert parse_channel_metadata({}) == {}
+
+
+def test_parse_channel_about():
+ about = {
+ "description": "Official channel.",
+ "country": "United States",
+ "subscriberCountText": "45.6M subscribers",
+ "viewCountText": "1,132,903,744 views",
+ "videoCountText": "1,471 videos",
+ "joinedDateText": {"content": "Joined Feb 8, 2005"},
+ }
+ out = parse_channel_about(about)
+ assert out["channelDescription"] == "Official channel."
+ assert out["channelLocation"] == "United States"
+ assert out["numberOfSubscribers"] == 45_600_000
+ assert out["channelTotalViews"] == 1_132_903_744
+ assert out["channelTotalVideos"] == 1_471
+ assert out["channelJoinedDate"] == "Feb 8, 2005"
+
+
+def test_parse_description_links():
+ initial = {
+ "attributedDescription": {
+ "content": "See #tag and my site here",
+ "commandRuns": [
+ {
+ "startIndex": 4,
+ "length": 4,
+ "onTap": {
+ "innertubeCommand": {"urlEndpoint": {"url": "/hashtag/tag"}}
+ },
+ },
+ {
+ "startIndex": 21,
+ "length": 4,
+ "onTap": {
+ "innertubeCommand": {
+ "commandMetadata": {
+ "webCommandMetadata": {
+ "url": "https://www.youtube.com/redirect?q=https%3A%2F%2Fexample.com%2Fp&v=x"
+ }
+ }
+ }
+ },
+ },
+ ],
+ }
+ }
+ links = parse_description_links(initial)
+ assert links == [
+ {"url": "https://www.youtube.com/hashtag/tag", "text": "#tag"},
+ {"url": "https://example.com/p", "text": "here"},
+ ]
+ assert parse_description_links({}) is None
+
+
+def test_channel_about_tokens():
+ initial = {
+ "a": {
+ "showEngagementPanelEndpoint": {
+ "x": {"continuationCommand": {"token": "T1"}}
+ }
+ },
+ "b": {
+ "showEngagementPanelEndpoint": {
+ "y": {"continuationCommand": {"token": "T2"}}
+ }
+ },
+ }
+ assert channel_about_tokens(initial) == ["T1", "T2"]
+ assert channel_about_tokens({}) == []
+
+
+def test_parse_search_response():
+ data = {
+ "contents": [
+ {
+ "videoRenderer": {
+ "videoId": "mx3g7XoPVNQ",
+ "title": {"runs": [{"text": "A bad day"}]},
+ "detailedMetadataSnippets": [
+ {"snippetText": {"runs": [{"text": "become an engineer"}]}}
+ ],
+ "publishedTimeText": {"simpleText": "7 days ago"},
+ "lengthText": {"simpleText": "8:39"},
+ "shortViewCountText": {"simpleText": "451K views"},
+ "thumbnail": {
+ "thumbnails": [
+ {
+ "url": "https://i.ytimg.com/vi/x/hq.jpg",
+ "width": 360,
+ "height": 202,
+ }
+ ]
+ },
+ "ownerText": {
+ "runs": [
+ {
+ "text": "Life of Boris",
+ "navigationEndpoint": {
+ "browseEndpoint": {
+ "browseId": "UCS5tt2z_DFvG7-39J3aE-bQ",
+ "canonicalBaseUrl": "/@LifeofBoris",
+ }
+ },
+ }
+ ]
+ },
+ }
+ }
+ ],
+ "continuationCommand": {"token": "TOKEN123"},
+ }
+ items, token = parse_search_response(data)
+ assert token == "TOKEN123"
+ assert len(items) == 1
+ item = items[0]
+ assert item["id"] == "mx3g7XoPVNQ"
+ assert item["title"] == "A bad day"
+ assert item["viewCount"] == 451_000
+ assert item["duration"] == "8:39"
+ assert item["publishedTimeText"] == "7 days ago"
+ assert item["date"] is None # list pages have no real date
+ assert item["channelName"] == "Life of Boris"
+ assert item["channelId"] == "UCS5tt2z_DFvG7-39J3aE-bQ"
+ assert item["channelUrl"] == "https://www.youtube.com/@LifeofBoris"
+ assert item["channelUsername"] == "LifeofBoris"
+
+
+def test_parse_channel_shorts():
+ data = {
+ "richItemRenderer": {
+ "content": {
+ "shortsLockupViewModel": {
+ "entityId": "shorts-shelf-item-0UfLo9SOUeE",
+ "onTap": {
+ "innertubeCommand": {
+ "reelWatchEndpoint": {"videoId": "0UfLo9SOUeE"}
+ }
+ },
+ "overlayMetadata": {
+ "primaryText": {"content": "is the west coast the best?"},
+ "secondaryText": {"content": "812 views"},
+ },
+ "thumbnailViewModel": {
+ "image": {
+ "sources": [
+ {
+ "url": "https://i.ytimg.com/s.jpg",
+ "width": 405,
+ "height": 720,
+ }
+ ]
+ }
+ },
+ }
+ }
+ },
+ "continuationCommand": {"token": "SHORTSTOKEN"},
+ }
+ items, token = parse_channel_shorts(data)
+ assert token == "SHORTSTOKEN"
+ assert len(items) == 1
+ it = items[0]
+ assert it["id"] == "0UfLo9SOUeE"
+ assert it["url"] == "https://www.youtube.com/shorts/0UfLo9SOUeE"
+ assert it["title"] == "is the west coast the best?"
+ assert it["viewCount"] == 812
+ assert it["thumbnailUrl"] == "https://i.ytimg.com/s.jpg"
+
+
+def test_parse_playlist_video_ids():
+ # Playlists now return lockupViewModel with contentId (not playlistVideoRenderer).
+ # Guards against the exact renderer-migration that silently broke the flow:
+ # keep videos (11-char ids) in order, dedup, and drop non-video lockups.
+ data = {
+ "contents": {
+ "items": [
+ {
+ "lockupViewModel": {
+ "contentId": "fNk_zzaMoSs",
+ "contentType": "VIDEO",
+ }
+ },
+ {
+ "lockupViewModel": {
+ "contentId": "k7RM-ot2NWY",
+ "contentType": "VIDEO",
+ }
+ },
+ {
+ "lockupViewModel": {
+ "contentId": "fNk_zzaMoSs",
+ "contentType": "VIDEO",
+ }
+ }, # dup
+ ]
+ },
+ # a playlist self-lockup (non-video, longer id) that must be ignored
+ "sidebar": {
+ "lockupViewModel": {"contentId": "PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab"}
+ },
+ "continuationItemRenderer": {
+ "continuationEndpoint": {"continuationCommand": {"token": "PAGE2"}}
+ },
+ }
+ ids, token = parse_playlist_video_ids(data)
+ assert ids == ["fNk_zzaMoSs", "k7RM-ot2NWY"]
+ assert token == "PAGE2"
+
+
+# --- search filter (sp=) encoder --------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "kwargs,expected",
+ [
+ ({}, None),
+ ({"sortingOrder": "relevance"}, None), # default sort → no bytes
+ ({"sortingOrder": "rating"}, "CAE="),
+ ({"sortingOrder": "date"}, "CAI="),
+ ({"sortingOrder": "views"}, "CAM="),
+ ({"videoType": "video"}, "EgIQAQ=="),
+ ({"videoType": "movie"}, "EgIQBA=="),
+ ({"dateFilter": "hour"}, "EgIIAQ=="),
+ ({"dateFilter": "year"}, "EgIIBQ=="),
+ ({"lengthFilter": "under4"}, "EgIYAQ=="),
+ ({"lengthFilter": "between420"}, "EgIYAw=="),
+ ({"lengthFilter": "plus20"}, "EgIYAg=="),
+ ],
+)
+def test_build_search_params_matches_youtube_tokens(kwargs, expected):
+ assert build_search_params(YouTubeScrapeInput(**kwargs)) == expected
+
+
+def test_build_search_params_combines_filters():
+ # sort=date + upload=week + type=video + HD + subtitles, all in one token.
+ token = build_search_params(
+ YouTubeScrapeInput(
+ sortingOrder="date",
+ dateFilter="week",
+ videoType="video",
+ isHD=True,
+ hasSubtitles=True,
+ )
+ )
+ raw = base64.b64decode(token)
+ # top-level: field1 (sort=2), field2 (Filters submessage)
+ assert raw[:2] == b"\x08\x02" # sort = date
+ assert b"\x12" in raw # Filters message tag present
+ # Filters payload contains upload_date=week(3), type=video(1), hd, subtitles
+ assert b"\x08\x03" in raw # uploadDate = week
+ assert b"\x10\x01" in raw # type = video
+ assert b"\x20\x01" in raw # feature 4 (HD) = 1
+ assert b"\x28\x01" in raw # feature 5 (subtitles) = 1
+
+
+# --- location & collaborators ------------------------------------------------
+
+
+def _primary_info(super_title_link):
+ return {
+ "contents": {
+ "twoColumnWatchNextResults": {
+ "results": {
+ "results": {
+ "contents": [
+ {
+ "videoPrimaryInfoRenderer": {
+ "superTitleLink": super_title_link
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+
+
+def test_parse_location_from_geo_tag_label():
+ initial = _primary_info(
+ {
+ "runs": [{"text": "ROME"}],
+ "accessibility": {
+ "accessibilityData": {
+ "label": "Link to a location restricted search for videos geo tagged with Rome"
+ }
+ },
+ }
+ )
+ assert parse_location(initial) == "Rome"
+
+
+def test_parse_location_hashtag_supertitle_is_none():
+ # Same slot, but hashtags carry no geo-tag a11y label.
+ initial = _primary_info({"runs": [{"text": "#music"}, {"text": "#video"}]})
+ assert parse_location(initial) is None
+
+
+def test_parse_location_absent_is_none():
+ assert parse_location({}) is None
+
+
+def _owner(video_owner_renderer):
+ return {
+ "contents": {
+ "twoColumnWatchNextResults": {
+ "results": {
+ "results": {
+ "contents": [
+ {
+ "videoSecondaryInfoRenderer": {
+ "owner": {
+ "videoOwnerRenderer": video_owner_renderer
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+
+
+def _collab_row(name, base_url):
+ return {
+ "listItemViewModel": {
+ "title": {
+ "content": name,
+ "commandRuns": [
+ {
+ "onTap": {
+ "innertubeCommand": {
+ "browseEndpoint": {
+ "browseId": "UCx",
+ "canonicalBaseUrl": base_url,
+ }
+ }
+ }
+ }
+ ],
+ },
+ # A nested subscribe submenu — must NOT be picked up as a collaborator.
+ "subscribeMenu": {
+ "listItemViewModel": {"title": {"content": "Unsubscribe"}}
+ },
+ }
+ }
+
+
+def test_parse_collaborators_from_dialog():
+ initial = _owner(
+ {
+ "attributedTitle": {
+ "content": "Alice and Bob",
+ "commandRuns": [
+ {
+ "onTap": {
+ "innertubeCommand": {
+ "showDialogCommand": {
+ "panelLoadingStrategy": {
+ "inlineContent": {
+ "dialogViewModel": {
+ "header": {
+ "dialogHeaderViewModel": {
+ "headline": {
+ "content": "Collaborators"
+ }
+ }
+ },
+ "customContent": {
+ "listViewModel": {
+ "listItems": [
+ _collab_row(
+ "Alice", "/@alice"
+ ),
+ _collab_row(
+ "Bob",
+ "/channel/UCbob123",
+ ),
+ ]
+ }
+ },
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ],
+ }
+ }
+ )
+ collaborators = parse_collaborators(initial)
+ assert collaborators == [
+ {"name": "Alice", "username": "alice", "url": "https://www.youtube.com/@alice"},
+ {
+ "name": "Bob",
+ "username": None,
+ "url": "https://www.youtube.com/channel/UCbob123",
+ },
+ ]
+
+
+def test_parse_collaborators_single_owner_is_none():
+ # Ordinary videos use title.runs, not attributedTitle.
+ initial = _owner({"title": {"runs": [{"text": "Some Channel"}]}})
+ assert parse_collaborators(initial) is None
+
+
+def test_parse_translation_from_next():
+ next_data = {
+ "contents": {
+ "twoColumnWatchNextResults": {
+ "results": {
+ "results": {
+ "contents": [
+ {
+ "videoPrimaryInfoRenderer": {
+ "title": {
+ "runs": [
+ {"text": "Título "},
+ {"text": "traducido"},
+ ]
+ }
+ }
+ },
+ {
+ "videoSecondaryInfoRenderer": {
+ "attributedDescription": {
+ "content": "Descripción traducida"
+ }
+ }
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+ title, description = parse_translation(next_data)
+ assert title == "Título traducido"
+ assert description == "Descripción traducida"
+
+
+def test_parse_translation_description_runs_fallback():
+ next_data = {
+ "videoSecondaryInfoRenderer": {
+ "description": {"runs": [{"text": "old "}, {"text": "style"}]}
+ }
+ }
+ title, description = parse_translation(next_data)
+ assert title is None
+ assert description == "old style"
+
+
+# --- comments ----------------------------------------------------------------
+
+
+def _comment_cep(cid, *, level=0, hearted=False, owner=False, replies="5"):
+ """Mirror a real commentEntityPayload's relevant fields."""
+ toolbar = {"likeCountNotliked": "1.2K", "replyCount": replies}
+ if hearted:
+ toolbar["creatorThumbnailUrl"] = "https://yt3.ggpht.com/heart"
+ return {
+ "properties": {
+ "commentId": cid,
+ "content": {"content": f"text {cid}"},
+ "publishedTime": "2 days ago",
+ "replyLevel": level,
+ },
+ "author": {"displayName": f"@user{cid}", "isCreator": owner},
+ "toolbar": toolbar,
+ }
+
+
+def _next_comments_response():
+ """A /next comments response: sort menu, two threads, a page token."""
+
+ def _reply_loader(token):
+ return {
+ "continuationItemRenderer": {
+ "continuationEndpoint": {"continuationCommand": {"token": token}}
+ }
+ }
+
+ def _thread(cid, reply_token):
+ return {
+ "commentThreadRenderer": {
+ "commentViewModel": {"commentViewModel": {"commentId": cid}},
+ "replies": {
+ "commentRepliesRenderer": {"contents": [_reply_loader(reply_token)]}
+ },
+ }
+ }
+
+ return {
+ "frameworkUpdates": {
+ "entityBatchUpdate": {
+ "mutations": [
+ {
+ "payload": {
+ "commentEntityPayload": _comment_cep(
+ "C1", hearted=True, owner=True
+ )
+ }
+ },
+ {"payload": {"commentEntityPayload": _comment_cep("C2")}},
+ ]
+ }
+ },
+ "onResponseReceivedEndpoints": [
+ {
+ "reloadContinuationItemsCommand": {
+ "continuationItems": [
+ _thread("C1", "REPLYTOK1"),
+ _thread("C2", "REPLYTOK2"),
+ {
+ "continuationItemRenderer": {
+ "continuationEndpoint": {
+ "continuationCommand": {"token": "PAGE2"}
+ }
+ }
+ },
+ ]
+ }
+ }
+ ],
+ "header": {
+ "sortFilterSubMenuRenderer": {
+ "subMenuItems": [
+ {
+ "title": "Top",
+ "serviceEndpoint": {"continuationCommand": {"token": "TOPTOK"}},
+ },
+ {
+ "title": "Newest",
+ "serviceEndpoint": {"continuationCommand": {"token": "NEWTOK"}},
+ },
+ ]
+ }
+ },
+ }
+
+
+def test_parse_comment_entities():
+ data = _next_comments_response()
+ comments = parse_comment_entities(data)
+ assert len(comments) == 2
+ first = comments[0]
+ assert first["cid"] == "C1"
+ assert first["comment"] == "text C1"
+ assert first["author"] == "@userC1"
+ assert first["voteCount"] == 1200 # "1.2K"
+ assert first["replyCount"] == 5
+ assert first["type"] == "comment"
+ assert first["hasCreatorHeart"] is True
+ assert first["authorIsChannelOwner"] is True
+ # second is a plain, non-hearted comment
+ assert comments[1]["hasCreatorHeart"] is False
+ assert comments[1]["authorIsChannelOwner"] is False
+
+
+def test_comment_entity_reply_level_and_empty_reply_count():
+ reply = parse_comment_entities(
+ {
+ "frameworkUpdates": {
+ "entityBatchUpdate": {
+ "mutations": [
+ {
+ "payload": {
+ "commentEntityPayload": _comment_cep(
+ "R1", level=1, replies=""
+ )
+ }
+ }
+ ]
+ }
+ }
+ }
+ )[0]
+ assert reply["type"] == "reply"
+ assert reply["replyCount"] is None # empty string -> None
+
+
+def test_comment_sort_tokens():
+ tokens = comment_sort_tokens(_next_comments_response())
+ assert tokens == {"Top": "TOPTOK", "Newest": "NEWTOK"}
+
+
+def test_comment_reply_tokens():
+ tokens = comment_reply_tokens(_next_comments_response())
+ assert tokens == {"C1": "REPLYTOK1", "C2": "REPLYTOK2"}
+
+
+def test_comment_next_token_is_trailing_bare_continuation():
+ # The page token is the last top-level continuationItemRenderer, not a
+ # reply loader nested inside a thread.
+ assert comment_next_token(_next_comments_response()) == "PAGE2"
+
+
+def test_comment_section_token_from_watch_page():
+ initial = {
+ "contents": {
+ "twoColumnWatchNextResults": {
+ "results": {
+ "results": {
+ "contents": [
+ {
+ "itemSectionRenderer": {
+ "sectionIdentifier": "comment-item-section",
+ "contents": [
+ {
+ "continuationItemRenderer": {
+ "continuationEndpoint": {
+ "continuationCommand": {
+ "token": "SECTIONTOK"
+ }
+ }
+ }
+ }
+ ],
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ assert comment_section_token(initial) == "SECTIONTOK"
+
+
+# --- url resolution ----------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "url,kind,value",
+ [
+ ("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "video", "dQw4w9WgXcQ"),
+ ("https://youtu.be/dQw4w9WgXcQ", "video", "dQw4w9WgXcQ"),
+ ("https://www.youtube.com/shorts/abc123", "video", "abc123"),
+ ("https://www.youtube.com/@Apify", "channel", "Apify"),
+ (
+ "https://www.youtube.com/channel/UC123456789abc/videos",
+ "channel",
+ "UC123456789abc",
+ ),
+ ("https://www.youtube.com/playlist?list=PL123", "playlist", "PL123"),
+ (
+ "https://www.youtube.com/results?search_query=web+scraping",
+ "search",
+ "web scraping",
+ ),
+ ("https://www.youtube.com/hashtag/tech", "hashtag", "tech"),
+ ],
+)
+def test_resolve_url(url, kind, value):
+ resolved = resolve_url(url)
+ assert resolved is not None
+ assert resolved.kind == kind
+ assert resolved.value == value
+
+
+def test_resolve_url_unrecognized():
+ assert resolve_url("https://example.com/foo") is None
+
+
+# --- optional: exercise captured real fixtures if present --------------------
+
+
+def test_parse_captured_video_fixture_if_present():
+ player_path = _FIXTURE_DIR / "video_player_response.json"
+ initial_path = _FIXTURE_DIR / "video_initial_data.json"
+ if not player_path.exists():
+ pytest.skip("no captured fixture (run scripts/e2e_youtube_scraper.py first)")
+ html = (
+ ""
+ )
+ if initial_path.exists():
+ html += (
+ ""
+ )
+ result = parse_video_page(html)
+ assert result is not None
+ assert result["id"]
+ assert result["title"]
diff --git a/surfsense_backend/tests/unit/platforms/youtube/test_subtitles_retry.py b/surfsense_backend/tests/unit/platforms/youtube/test_subtitles_retry.py
new file mode 100644
index 000000000..a1636ed23
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/youtube/test_subtitles_retry.py
@@ -0,0 +1,77 @@
+"""Offline tests for the subtitles blocked-IP retry (rotate-on-block, no network.
+
+Stubs ``_build_client``/``get_requests_proxies`` so the RequestBlocked retry
+path is exercised deterministically: retries only when a proxy is configured,
+each attempt gets a fresh client, and the final block is re-raised.
+"""
+
+from __future__ import annotations
+
+import pytest
+from youtube_transcript_api import RequestBlocked
+
+from app.proprietary.platforms.youtube import subtitles
+
+
+class _FakeTranscript:
+ is_generated = True
+ language_code = "en"
+
+ def fetch(self):
+ return [] # formatters iterate snippets; empty is fine here
+
+
+class _FakeTranscriptList:
+ def find_transcript(self, codes):
+ return _FakeTranscript()
+
+
+class _FakeApi:
+ """One 'IP': blocks if ``blocked``, else returns a transcript list."""
+
+ def __init__(self, blocked: bool) -> None:
+ self.blocked = blocked
+
+ def list(self, video_id: str):
+ if self.blocked:
+ raise RequestBlocked(video_id)
+ return _FakeTranscriptList()
+
+
+def _install(monkeypatch, outcomes: list[bool], proxied: bool) -> list[_FakeApi]:
+ """Each ``_build_client`` call pops the next outcome (True = blocked)."""
+ built: list[_FakeApi] = []
+
+ def fake_build():
+ api = _FakeApi(outcomes[len(built)])
+ built.append(api)
+ return api
+
+ monkeypatch.setattr(subtitles, "_build_client", fake_build)
+ monkeypatch.setattr(
+ subtitles,
+ "get_requests_proxies",
+ lambda: {"http": "http://p", "https": "http://p"} if proxied else None,
+ )
+ return built
+
+
+def test_blocked_then_success_retries_with_fresh_client(monkeypatch):
+ built = _install(monkeypatch, [True, True, False], proxied=True)
+ track = subtitles._fetch_subtitles_sync("vid", "en", "plaintext", False)
+ assert track.language == "en"
+ assert len(built) == 3 # two blocked attempts + one success, each a new client
+
+
+def test_all_attempts_blocked_reraises(monkeypatch):
+ built = _install(monkeypatch, [True] * 10, proxied=True)
+ with pytest.raises(RequestBlocked):
+ subtitles._fetch_subtitles_sync("vid", "en", "plaintext", False)
+ assert len(built) == subtitles._MAX_ROTATIONS + 1
+
+
+def test_no_proxy_means_single_attempt(monkeypatch):
+ built = _install(monkeypatch, [True] * 10, proxied=False)
+ with pytest.raises(RequestBlocked):
+ subtitles._fetch_subtitles_sync("vid", "en", "plaintext", False)
+ assert len(built) == 1 # same egress IP; retrying would be futile
diff --git a/surfsense_backend/tests/unit/podcasts/test_api_schemas.py b/surfsense_backend/tests/unit/podcasts/test_api_schemas.py
index 41664ac64..59a4b7abf 100644
--- a/surfsense_backend/tests/unit/podcasts/test_api_schemas.py
+++ b/surfsense_backend/tests/unit/podcasts/test_api_schemas.py
@@ -22,7 +22,7 @@ def _podcast(*, status: PodcastStatus = PodcastStatus.PENDING, **columns) -> Pod
"""A persisted-looking row: the id and created_at a saved podcast would carry."""
podcast = Podcast(
title="Episode",
- search_space_id=3,
+ workspace_id=3,
status=status,
spec_version=1,
**columns,
diff --git a/surfsense_backend/tests/unit/proprietary/__init__.py b/surfsense_backend/tests/unit/proprietary/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/proprietary/web_crawler/__init__.py b/surfsense_backend/tests/unit/proprietary/web_crawler/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/proprietary/web_crawler/test_captcha.py b/surfsense_backend/tests/unit/proprietary/web_crawler/test_captcha.py
new file mode 100644
index 000000000..de7228a76
--- /dev/null
+++ b/surfsense_backend/tests/unit/proprietary/web_crawler/test_captcha.py
@@ -0,0 +1,241 @@
+"""Unit tests for the proprietary captcha page_action factory (Phase 3d).
+
+The browser/solver boundary is mocked: a fake Playwright ``page`` and a
+monkeypatched ``_harvest_token`` / injection. We assert the glue logic —
+detection, proxy reformatting, attempt counting + state surfacing, the per-URL
+cap, and the no-balance process latch.
+"""
+
+import pytest
+
+from app.proprietary.web_crawler import captcha as cap
+from app.utils.captcha import CaptchaConfig
+
+pytestmark = pytest.mark.unit
+
+
+def _cfg(**overrides) -> CaptchaConfig:
+ base = {
+ "enabled": True,
+ "solving_site": "capsolver",
+ "api_key": "key-123",
+ "max_attempts_per_url": 1,
+ "timeout_s": 30,
+ "captcha_type_default": "v2",
+ "v3_min_score": 0.7,
+ "v3_action": "verify",
+ }
+ base.update(overrides)
+ return CaptchaConfig(**base)
+
+
+class _FakeEl:
+ def __init__(self, attrs: dict[str, str]):
+ self._attrs = attrs
+
+ def get_attribute(self, name: str) -> str | None:
+ return self._attrs.get(name)
+
+
+class _FakePage:
+ """Minimal sync-Playwright-ish page for detection/injection tests."""
+
+ def __init__(
+ self, widgets=None, iframes=None, scripts=None, url="https://t.test/p"
+ ):
+ self._widgets = widgets or {} # selector -> _FakeEl
+ self._iframes = iframes or [] # list[_FakeEl]
+ self._scripts = scripts or [] # list[_FakeEl]
+ self.url = url
+ self.evaluated: list = []
+
+ def query_selector(self, selector: str):
+ return self._widgets.get(selector)
+
+ def query_selector_all(self, selector: str):
+ if selector == "iframe[src]":
+ return self._iframes
+ if selector == "script[src]":
+ return self._scripts
+ return []
+
+ def evaluate(self, js, arg=None):
+ self.evaluated.append((js, arg))
+ if "navigator.userAgent" in js:
+ return "UA/1.0"
+ return None
+
+ def wait_for_load_state(self, *_a, **_k):
+ return None
+
+
+@pytest.fixture(autouse=True)
+def _clear_latch():
+ cap.reset_solver_latch()
+ yield
+ cap.reset_solver_latch()
+
+
+# --- proxy reformat --------------------------------------------------------
+
+
+class TestProxyReformat:
+ def test_with_auth(self):
+ assert (
+ cap.proxy_url_to_captchatools("http://user:pass@1.2.3.4:8080")
+ == "1.2.3.4:8080:user:pass"
+ )
+
+ def test_without_auth(self):
+ assert cap.proxy_url_to_captchatools("http://1.2.3.4:8080") == "1.2.3.4:8080"
+
+ def test_none_and_garbage(self):
+ assert cap.proxy_url_to_captchatools(None) is None
+ assert cap.proxy_url_to_captchatools("not-a-url") is None
+
+
+# --- detection -------------------------------------------------------------
+
+
+class TestDetect:
+ def test_recaptcha_v2_widget(self):
+ page = _FakePage(
+ widgets={".g-recaptcha[data-sitekey]": _FakeEl({"data-sitekey": "SK_V2"})}
+ )
+ assert cap.detect_challenge(page, _cfg()) == ("v2", "SK_V2")
+
+ def test_hcaptcha_widget(self):
+ page = _FakePage(
+ widgets={".h-captcha[data-sitekey]": _FakeEl({"data-sitekey": "SK_H"})}
+ )
+ assert cap.detect_challenge(page, _cfg()) == ("hcaptcha", "SK_H")
+
+ def test_iframe_fallback(self):
+ page = _FakePage(
+ iframes=[_FakeEl({"src": "https://www.google.com/recaptcha/api2?k=SK_IF"})]
+ )
+ assert cap.detect_challenge(page, _cfg()) == ("v2", "SK_IF")
+
+ def test_v3_render_param_when_default_v3(self):
+ page = _FakePage(
+ scripts=[
+ _FakeEl({"src": "https://www.google.com/recaptcha/api.js?render=SK_V3"})
+ ]
+ )
+ assert cap.detect_challenge(page, _cfg(captcha_type_default="v3")) == (
+ "v3",
+ "SK_V3",
+ )
+
+ def test_no_challenge(self):
+ assert cap.detect_challenge(_FakePage(), _cfg()) is None
+
+
+# --- factory / page_action -------------------------------------------------
+
+
+class TestFactoryGating:
+ def test_returns_none_when_disabled(self):
+ state = {"attempts": 0, "solved": False}
+ assert build_action(state, _cfg(enabled=False)) is None
+
+ def test_returns_none_when_latched(self):
+ cap._latch_solver("test")
+ state = {"attempts": 0, "solved": False}
+ assert build_action(state, _cfg()) is None
+
+
+def build_action(state, cfg, proxy="http://u:p@1.2.3.4:9000"):
+ return cap.build_captcha_page_action(state, proxy, cfg)
+
+
+class TestPageAction:
+ def test_solves_and_records(self, monkeypatch):
+ captured = {}
+
+ def _fake_harvest(cfg, ctype, sitekey, page_url, proxy, ua):
+ captured.update(
+ ctype=ctype, sitekey=sitekey, page_url=page_url, proxy=proxy, ua=ua
+ )
+ return "TOKEN"
+
+ injected = {}
+ monkeypatch.setattr(cap, "_harvest_token", _fake_harvest)
+ monkeypatch.setattr(
+ cap,
+ "_inject_and_submit",
+ lambda page, ctype, token: injected.update(ctype=ctype, token=token),
+ )
+
+ state = {"attempts": 0, "solved": False}
+ action = build_action(state, _cfg())
+ page = _FakePage(
+ widgets={".g-recaptcha[data-sitekey]": _FakeEl({"data-sitekey": "SK"})}
+ )
+ action(page)
+
+ assert state == {"attempts": 1, "solved": True}
+ # Solver egressed from the crawl's proxy, reformatted, with the page UA.
+ assert captured["proxy"] == "1.2.3.4:9000:u:p"
+ assert captured["ua"] == "UA/1.0"
+ assert captured["ctype"] == "v2"
+ assert injected == {"ctype": "v2", "token": "TOKEN"}
+
+ def test_no_challenge_no_attempt(self, monkeypatch):
+ monkeypatch.setattr(
+ cap, "_harvest_token", lambda *a, **k: pytest.fail("should not solve")
+ )
+ state = {"attempts": 0, "solved": False}
+ build_action(state, _cfg())(_FakePage())
+ assert state == {"attempts": 0, "solved": False}
+
+ def test_per_url_attempt_cap(self, monkeypatch):
+ calls = {"n": 0}
+
+ def _h(*_a, **_k):
+ calls["n"] += 1
+ return "TOKEN"
+
+ monkeypatch.setattr(cap, "_harvest_token", _h)
+ monkeypatch.setattr(cap, "_inject_and_submit", lambda *a, **k: None)
+
+ # Already at the cap → no further solve.
+ state = {"attempts": 1, "solved": False}
+ page = _FakePage(
+ widgets={".g-recaptcha[data-sitekey]": _FakeEl({"data-sitekey": "SK"})}
+ )
+ build_action(state, _cfg(max_attempts_per_url=1))(page)
+ assert calls["n"] == 0
+ assert state["attempts"] == 1
+
+ def test_empty_token_counts_attempt_but_not_solved(self, monkeypatch):
+ monkeypatch.setattr(cap, "_harvest_token", lambda *a, **k: None)
+ monkeypatch.setattr(
+ cap, "_inject_and_submit", lambda *a, **k: pytest.fail("no inject")
+ )
+ state = {"attempts": 0, "solved": False}
+ page = _FakePage(
+ widgets={".g-recaptcha[data-sitekey]": _FakeEl({"data-sitekey": "SK"})}
+ )
+ build_action(state, _cfg())(page)
+ assert state == {"attempts": 1, "solved": False}
+
+ def test_no_balance_latches_and_stops(self, monkeypatch):
+ class NoBalanceError(Exception):
+ pass
+
+ def _boom(*_a, **_k):
+ raise NoBalanceError("out of balance")
+
+ monkeypatch.setattr(cap, "_harvest_token", _boom)
+ state = {"attempts": 0, "solved": False}
+ page = _FakePage(
+ widgets={".g-recaptcha[data-sitekey]": _FakeEl({"data-sitekey": "SK"})}
+ )
+ build_action(state, _cfg(max_attempts_per_url=3))(page)
+
+ # Attempt counted, not solved, and the process latch is tripped so the
+ # next factory build refuses to wire solving at all (no retry loop).
+ assert state == {"attempts": 1, "solved": False}
+ assert cap.solver_latched() is True
+ assert build_action({"attempts": 0, "solved": False}, _cfg()) is None
diff --git a/surfsense_backend/tests/unit/proprietary/web_crawler/test_connector.py b/surfsense_backend/tests/unit/proprietary/web_crawler/test_connector.py
new file mode 100644
index 000000000..8db7c1ed1
--- /dev/null
+++ b/surfsense_backend/tests/unit/proprietary/web_crawler/test_connector.py
@@ -0,0 +1,499 @@
+"""Unit tests for ``WebCrawlerConnector.crawl_url`` outcome semantics (Phase 3a).
+
+These exercise the Scrapling tier ladder and the explicit ``CrawlOutcome``
+contract that Phase 3c bills on, by stubbing the per-tier fetch helpers so the
+ladder logic is tested deterministically without launching browsers/HTTP.
+"""
+
+import pytest
+
+from app.proprietary.web_crawler import (
+ CrawlOutcomeStatus,
+ WebCrawlerConnector,
+ connector as connector_module,
+)
+from app.utils.crawl import BlockType
+
+pytestmark = pytest.mark.unit
+
+
+def _result(tier: str) -> dict:
+ return {
+ "content": "hello world",
+ "metadata": {"source": "https://example.com", "title": "Example"},
+ "crawler_type": tier,
+ }
+
+
+async def test_invalid_url_is_failed() -> None:
+ """A URL that fails validation never reaches a tier and is FAILED."""
+ outcome = await WebCrawlerConnector().crawl_url("not a url")
+
+ assert outcome.status is CrawlOutcomeStatus.FAILED
+ assert outcome.result is None
+ assert "Invalid URL" in (outcome.error or "")
+
+
+async def test_static_tier_success_short_circuits(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Static success returns SUCCESS and never touches the browser tiers."""
+ crawler = WebCrawlerConnector()
+ later_calls: list[str] = []
+
+ async def _static(_url: str, *_args) -> dict:
+ return _result("scrapling-static")
+
+ async def _record_dynamic(_url: str, *_args) -> None:
+ later_calls.append("dynamic")
+ return None
+
+ async def _record_stealthy(_url: str, *_args) -> None:
+ later_calls.append("stealthy")
+ return None
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _static)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _record_dynamic)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _record_stealthy)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.SUCCESS
+ assert outcome.tier == "scrapling-static"
+ assert outcome.result is not None
+ assert outcome.result["crawler_type"] == "scrapling-static"
+ assert later_calls == []
+
+
+async def test_escalates_to_dynamic_on_static_miss(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Static empty extraction escalates to the dynamic tier."""
+ crawler = WebCrawlerConnector()
+
+ async def _empty(_url: str, *_args) -> None:
+ return None
+
+ async def _dynamic(_url: str, *_args) -> dict:
+ return _result("scrapling-dynamic")
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _dynamic)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.SUCCESS
+ assert outcome.tier == "scrapling-dynamic"
+
+
+def test_dropped_currency_amounts_detection() -> None:
+ """Fires only when the DOM has currency figures that the markdown lost."""
+ dropped = connector_module.dropped_currency_amounts
+ html = "Pro plan $49 /mo
"
+ assert dropped(html, "Pro plan without figures")
+ assert not dropped(html, "Pro plan $49/mo") # markdown kept it
+ assert not dropped("no prices here", "text")
+ # Country-agnostic: symbol-after-amount and ISO codes count too.
+ assert dropped("ab 49€ pro Monat", "ab pro Monat")
+ assert dropped("USD 2,500 per year", "per year")
+ # Script content is not visible: a JSON payload price must not trigger.
+ assert not dropped(
+ "hi", "hi"
+ )
+
+
+def test_build_result_repairs_pricing_card_loss() -> None:
+ """div-grid pricing cards dropped by trafilatura get recovered."""
+ cards = "".join(
+ f""
+ for name, price in (("Free", 0), ("Pro", 49), ("Enterprise", 199))
+ )
+ html = (
+ "Pricing "
+ "Simple pricing "
+ + "Choose the plan that fits your team best. " * 30
+ + "
"
+ )
+ result = WebCrawlerConnector()._build_result(
+ html, "https://x.com/pricing", "t", allow_raw_fallback=False
+ )
+ assert result is not None
+ assert "$49" in result["content"]
+ assert "$199" in result["content"]
+
+
+def test_looks_like_js_shell_thresholds() -> None:
+ """Shell = huge HTML AND near-empty extraction; either alone is healthy."""
+ shell = connector_module.looks_like_js_shell
+ assert shell(4_200_000, 597) # a16z.com/team
+ assert not shell(200_000, 13_092) # a16z investment-list: normal page
+ assert not shell(45_000, 1_356) # small brochure page: small is not thin
+ assert not shell(4_200_000, 50_000) # huge but content-rich (long article)
+
+
+async def test_thin_static_shell_escalates_to_dynamic(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A JS-shell static result escalates; the hydrated dynamic result wins."""
+ crawler = WebCrawlerConnector()
+
+ async def _thin_static(_url: str, *_args) -> dict:
+ return _result("scrapling-static") | {"thin_static": True}
+
+ async def _dynamic(_url: str, *_args) -> dict:
+ return _result("scrapling-dynamic")
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _thin_static)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _dynamic)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.SUCCESS
+ assert outcome.tier == "scrapling-dynamic"
+
+
+async def test_thin_static_is_fallback_when_browser_tiers_fail(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Browser tiers unavailable -> the partial static content still returns."""
+ crawler = WebCrawlerConnector()
+
+ async def _thin_static(_url: str, *_args) -> dict:
+ return _result("scrapling-static") | {"thin_static": True}
+
+ async def _unavailable(_url: str, *_args) -> None:
+ raise NotImplementedError("no subprocess support")
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _thin_static)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _unavailable)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _unavailable)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.SUCCESS
+ assert outcome.tier == "scrapling-static"
+ assert outcome.result is not None
+ assert "thin_static" not in outcome.result # internal tag never leaks
+
+
+async def test_all_tiers_empty_is_empty(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Every tier fetched but extracted nothing -> EMPTY (not billable)."""
+ crawler = WebCrawlerConnector()
+
+ async def _empty(_url: str, *_args) -> None:
+ return None
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _empty)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.EMPTY
+ assert outcome.result is None
+
+
+async def test_all_tiers_raise_is_failed(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Every tier raising (none reachable) -> FAILED with aggregated errors."""
+ crawler = WebCrawlerConnector()
+
+ async def _boom(_url: str, *_args) -> None:
+ raise RuntimeError("fetch exploded")
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _boom)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _boom)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _boom)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.FAILED
+ assert "fetch exploded" in (outcome.error or "")
+
+
+async def test_proxy_error_rotates_once_when_pool_backed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """03b: a pool-backed provider retries the tier once on a proxy error."""
+ crawler = WebCrawlerConnector()
+ calls = {"n": 0}
+
+ async def _flaky(_url: str, *_args) -> dict:
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise RuntimeError("connection refused by upstream proxy")
+ return _result("scrapling-static")
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _flaky)
+ monkeypatch.setattr(connector_module, "is_pool_backed", lambda: True)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.SUCCESS
+ assert outcome.tier == "scrapling-static"
+ assert calls["n"] == 2 # original attempt + one rotation retry
+
+
+async def test_proxy_error_no_retry_when_single_endpoint(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Single-endpoint providers skip the retry (no re-hit of the dead proxy)."""
+ crawler = WebCrawlerConnector()
+ static_calls = {"n": 0}
+
+ async def _proxy_err(_url: str, *_args) -> None:
+ static_calls["n"] += 1
+ raise RuntimeError("connection refused by upstream proxy")
+
+ async def _empty(_url: str, *_args) -> None:
+ return None
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _proxy_err)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _empty)
+ monkeypatch.setattr(connector_module, "is_pool_backed", lambda: False)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert static_calls["n"] == 1 # no retry
+ assert outcome.status is CrawlOutcomeStatus.EMPTY
+
+
+async def test_captcha_defaults_zero_on_non_stealthy_success(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """03d: a static success never touches captcha → fields stay 0/False."""
+ crawler = WebCrawlerConnector()
+
+ async def _static(_url: str, *_args) -> dict:
+ return _result("scrapling-static")
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _static)
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.SUCCESS
+ assert outcome.captcha_attempts == 0
+ assert outcome.captcha_solved is False
+
+
+async def test_captcha_state_surfaced_onto_outcome(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """03d: the stealthy tier's captcha_state is stamped onto the outcome,
+ even when the lower tiers missed and stealthy itself succeeds."""
+ crawler = WebCrawlerConnector()
+
+ async def _empty(_url: str, *_args) -> None:
+ return None
+
+ async def _stealthy(_url: str, captcha_state: dict, *_args) -> dict:
+ # Simulate the page_action having solved a captcha mid-fetch.
+ captcha_state["attempts"] = 2
+ captcha_state["solved"] = True
+ return _result("scrapling-stealthy")
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _stealthy)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.SUCCESS
+ assert outcome.tier == "scrapling-stealthy"
+ assert outcome.captcha_attempts == 2
+ assert outcome.captcha_solved is True
+
+
+async def test_captcha_attempts_surface_even_when_crawl_fails(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """03d: attempts are billed per-attempt → must surface on a FAILED outcome."""
+ crawler = WebCrawlerConnector()
+
+ async def _empty(_url: str, *_args) -> None:
+ return None
+
+ async def _stealthy_attempt_then_empty(
+ _url: str, captcha_state: dict, *_args
+ ) -> None:
+ captcha_state["attempts"] = 1 # solve attempted but crawl still empty
+ return None
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _stealthy_attempt_then_empty)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.EMPTY
+ assert outcome.captcha_attempts == 1
+ assert outcome.captcha_solved is False
+
+
+async def test_non_proxy_error_never_retries(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A non-proxy error is not retried even when pool-backed."""
+ crawler = WebCrawlerConnector()
+ calls = {"n": 0}
+
+ async def _boom(_url: str, *_args) -> None:
+ calls["n"] += 1
+ raise RuntimeError("totally unrelated failure")
+
+ async def _empty(_url: str, *_args) -> None:
+ return None
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _boom)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _empty)
+ monkeypatch.setattr(connector_module, "is_pool_backed", lambda: True)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert calls["n"] == 1 # not retried (not a proxy error)
+ assert outcome.status is CrawlOutcomeStatus.EMPTY
+
+
+async def test_invalid_url_block_type_defaults_unknown() -> None:
+ """03e: a pre-fetch FAILED carries the default UNKNOWN block_type."""
+ outcome = await WebCrawlerConnector().crawl_url("not a url")
+
+ assert outcome.status is CrawlOutcomeStatus.FAILED
+ assert outcome.block_type is BlockType.UNKNOWN
+
+
+async def test_block_type_surfaced_onto_outcome(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """03e: a tier's block classification (in block_state) is stamped onto the
+ outcome — additive only, never gating SUCCESS."""
+ crawler = WebCrawlerConnector()
+
+ async def _empty(_url: str, *_args) -> None:
+ return None
+
+ async def _stealthy_blocked(
+ _url: str, _captcha_state: dict, block_state: dict
+ ) -> None:
+ # Simulate _build_result having classified a Cloudflare interstitial.
+ block_state["block_type"] = BlockType.CLOUDFLARE
+ return None
+
+ monkeypatch.setattr(crawler, "_crawl_with_async_fetcher", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_dynamic", _empty)
+ monkeypatch.setattr(crawler, "_crawl_with_stealthy", _stealthy_blocked)
+
+ outcome = await crawler.crawl_url("https://example.com")
+
+ assert outcome.status is CrawlOutcomeStatus.EMPTY
+ assert outcome.block_type is BlockType.CLOUDFLARE
+
+
+def test_build_result_classifies_into_block_state() -> None:
+ """03e: _build_result labels the fetched page into the passed block_state."""
+ crawler = WebCrawlerConnector()
+ block_state: dict = {"block_type": BlockType.UNKNOWN}
+
+ cf_html = "Just a moment... "
+ result = crawler._build_result(
+ cf_html,
+ "https://example.com",
+ "scrapling-stealthy",
+ allow_raw_fallback=False,
+ status=403,
+ block_state=block_state,
+ )
+
+ # Cloudflare interstitial: no real content extracted (None) but classified.
+ assert result is None
+ assert block_state["block_type"] is BlockType.CLOUDFLARE
+
+
+async def test_static_4xx_is_classified(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """03e: a static-tier 4xx bot-gate is classified before the early return
+ (otherwise the cheapest/first tier's block signal would be lost)."""
+ crawler = WebCrawlerConnector()
+
+ class _Page:
+ status = 403
+ html_content = "Just a moment... "
+
+ class _AsyncFetcher:
+ @staticmethod
+ async def get(*_a, **_k):
+ return _Page()
+
+ monkeypatch.setattr(connector_module, "AsyncFetcher", _AsyncFetcher)
+ monkeypatch.setattr(connector_module, "get_proxy_url", lambda: None)
+
+ block_state: dict = {"block_type": BlockType.UNKNOWN}
+ result = await crawler._crawl_with_async_fetcher("https://example.com", block_state)
+
+ assert result is None # 4xx => fall through to next tier
+ assert block_state["block_type"] is BlockType.CLOUDFLARE
+
+
+class _FakeScrollPage:
+ """Playwright-page stand-in: height grows per scroll until a plateau."""
+
+ def __init__(self, heights: list[int]):
+ self._heights = heights
+ self._i = 0
+ self.scrolls = 0
+
+ def evaluate(self, script: str):
+ if "scrollHeight" in script and "scrollTo" not in script:
+ return self._heights[min(self._i, len(self._heights) - 1)]
+ self.scrolls += 1
+ self._i += 1
+ return None
+
+ def wait_for_timeout(self, _ms: int) -> None:
+ pass
+
+
+def test_scroll_to_bottom_stops_when_height_stops_growing() -> None:
+ page = _FakeScrollPage([1000, 2000, 3000, 3000])
+ assert connector_module.scroll_to_bottom(page) is page
+ assert page.scrolls == 3 # scrolled at 1000/2000/3000; 3000-again broke the loop
+
+
+def test_scroll_to_bottom_is_bounded_on_endless_feeds() -> None:
+ page = _FakeScrollPage([i * 1000 for i in range(1, 100)]) # never stabilizes
+ connector_module.scroll_to_bottom(page)
+ assert page.scrolls == connector_module._SCROLL_MAX_ROUNDS
+
+
+def test_scroll_to_bottom_swallows_page_errors() -> None:
+ class _Broken:
+ def evaluate(self, _script: str):
+ raise RuntimeError("target closed")
+
+ page = _Broken()
+ assert connector_module.scroll_to_bottom(page) is page # never raises
+
+
+def test_build_result_ok_on_real_content() -> None:
+ """03e: a normal 200 page with content classifies OK."""
+ crawler = WebCrawlerConnector()
+ block_state: dict = {"block_type": BlockType.UNKNOWN}
+
+ html = (
+ "" + ("Real content. " * 40) + " "
+ )
+ crawler._build_result(
+ html,
+ "https://example.com",
+ "scrapling-static",
+ allow_raw_fallback=False,
+ status=200,
+ block_state=block_state,
+ )
+
+ assert block_state["block_type"] is BlockType.OK
diff --git a/surfsense_backend/tests/unit/proprietary/web_crawler/test_connector_links.py b/surfsense_backend/tests/unit/proprietary/web_crawler/test_connector_links.py
new file mode 100644
index 000000000..958a905ca
--- /dev/null
+++ b/surfsense_backend/tests/unit/proprietary/web_crawler/test_connector_links.py
@@ -0,0 +1,29 @@
+"""``_build_result`` surfaces absolute page links for the spider to enqueue."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.proprietary.web_crawler import WebCrawlerConnector
+
+pytestmark = pytest.mark.unit
+
+
+def test_build_result_includes_absolute_links() -> None:
+ html = (
+ ""
+ 'A '
+ 'B '
+ ""
+ )
+
+ result = WebCrawlerConnector()._build_result(
+ html,
+ "https://example.com/",
+ "scrapling-static",
+ allow_raw_fallback=True,
+ )
+
+ assert result is not None
+ assert "https://example.com/a" in result["links"]
+ assert "https://example.com/b" in result["links"]
diff --git a/surfsense_backend/tests/unit/proprietary/web_crawler/test_site_crawler.py b/surfsense_backend/tests/unit/proprietary/web_crawler/test_site_crawler.py
new file mode 100644
index 000000000..1f8f64d54
--- /dev/null
+++ b/surfsense_backend/tests/unit/proprietary/web_crawler/test_site_crawler.py
@@ -0,0 +1,163 @@
+"""``crawl_site`` BFS behavior: depth, same-site scope, dedupe, and page caps.
+
+Boundary mocked: the engine (a fake ``crawl_url`` serving a canned link graph).
+NOT mocked: the frontier/depth/dedupe/scope logic under test.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.proprietary.web_crawler import CrawlOutcome, CrawlOutcomeStatus
+from app.proprietary.web_crawler.site_crawler import crawl_site
+
+pytestmark = pytest.mark.unit
+
+_SUCCESS = CrawlOutcomeStatus.SUCCESS
+_FAILED = CrawlOutcomeStatus.FAILED
+
+
+class _FakeEngine:
+ """Serves a canned ``(status, links)`` per URL and records fetch order."""
+
+ def __init__(self, graph: dict[str, tuple[CrawlOutcomeStatus, list[str]]]):
+ self._graph = graph
+ self.calls: list[str] = []
+
+ async def crawl_url(self, url: str) -> CrawlOutcome:
+ self.calls.append(url)
+ status, links = self._graph.get(url, (_FAILED, []))
+ if status is _SUCCESS:
+ return CrawlOutcome(
+ status=_SUCCESS,
+ result={
+ "content": f"C:{url}",
+ "metadata": {"title": url},
+ "links": links,
+ },
+ )
+ return CrawlOutcome(status=status, error="boom")
+
+
+async def test_depth_zero_fetches_only_the_seed() -> None:
+ engine = _FakeEngine({"https://e.com/": (_SUCCESS, ["https://e.com/a"])})
+
+ pages = await crawl_site(
+ engine, ["https://e.com/"], max_crawl_depth=0, max_crawl_pages=10
+ )
+
+ assert engine.calls == ["https://e.com/"]
+ assert len(pages) == 1
+ assert pages[0].depth == 0
+ assert pages[0].referrer is None
+ assert pages[0].content == "C:https://e.com/"
+
+
+async def test_depth_one_follows_same_site_links_only() -> None:
+ engine = _FakeEngine(
+ {
+ "https://e.com/": (
+ _SUCCESS,
+ ["https://e.com/a", "https://e.com/b", "https://other.com/x"],
+ ),
+ "https://e.com/a": (_SUCCESS, []),
+ "https://e.com/b": (_SUCCESS, []),
+ }
+ )
+
+ pages = await crawl_site(
+ engine, ["https://e.com/"], max_crawl_depth=1, max_crawl_pages=10
+ )
+
+ assert set(engine.calls) == {
+ "https://e.com/",
+ "https://e.com/a",
+ "https://e.com/b",
+ }
+ depths = {page.url: page.depth for page in pages}
+ assert depths["https://e.com/a"] == 1
+ referrers = {page.url: page.referrer for page in pages}
+ assert referrers["https://e.com/a"] == "https://e.com/"
+
+
+async def test_depth_caps_further_recursion() -> None:
+ engine = _FakeEngine(
+ {
+ "https://e.com/": (_SUCCESS, ["https://e.com/a"]),
+ "https://e.com/a": (_SUCCESS, ["https://e.com/b"]),
+ "https://e.com/b": (_SUCCESS, []),
+ }
+ )
+
+ pages = await crawl_site(
+ engine, ["https://e.com/"], max_crawl_depth=1, max_crawl_pages=10
+ )
+
+ # depth 1 reaches /a but must NOT descend to /b (depth 2).
+ assert set(engine.calls) == {"https://e.com/", "https://e.com/a"}
+ assert all(page.url != "https://e.com/b" for page in pages)
+
+
+async def test_max_pages_caps_total_fetches() -> None:
+ graph: dict[str, tuple[CrawlOutcomeStatus, list[str]]] = {
+ "https://e.com/": (_SUCCESS, [f"https://e.com/{i}" for i in range(10)])
+ }
+ for i in range(10):
+ graph[f"https://e.com/{i}"] = (_SUCCESS, [])
+ engine = _FakeEngine(graph)
+
+ pages = await crawl_site(
+ engine, ["https://e.com/"], max_crawl_depth=1, max_crawl_pages=3
+ )
+
+ assert len(pages) == 3
+ assert len(engine.calls) == 3
+
+
+async def test_dedupes_on_canonical_url() -> None:
+ engine = _FakeEngine(
+ {
+ "https://e.com/": (
+ _SUCCESS,
+ ["https://e.com/a", "https://e.com/a#frag", "https://e.com/a?"],
+ ),
+ "https://e.com/a": (_SUCCESS, ["https://e.com/"]), # links back to seed
+ }
+ )
+
+ await crawl_site(engine, ["https://e.com/"], max_crawl_depth=3, max_crawl_pages=10)
+
+ assert engine.calls.count("https://e.com/a") == 1
+ assert engine.calls.count("https://e.com/") == 1
+
+
+async def test_failed_page_is_recorded_and_not_expanded() -> None:
+ engine = _FakeEngine({"https://e.com/": (_FAILED, [])})
+
+ pages = await crawl_site(
+ engine, ["https://e.com/"], max_crawl_depth=2, max_crawl_pages=10
+ )
+
+ assert len(pages) == 1
+ assert pages[0].status is _FAILED
+ assert pages[0].content is None
+ assert pages[0].error == "boom"
+
+
+async def test_multiple_seeds_are_all_entry_points() -> None:
+ engine = _FakeEngine(
+ {
+ "https://a.com/": (_SUCCESS, []),
+ "https://b.com/": (_SUCCESS, []),
+ }
+ )
+
+ pages = await crawl_site(
+ engine,
+ ["https://a.com/", "https://b.com/"],
+ max_crawl_depth=0,
+ max_crawl_pages=10,
+ )
+
+ assert {page.url for page in pages} == {"https://a.com/", "https://b.com/"}
+ assert all(page.depth == 0 for page in pages)
diff --git a/surfsense_backend/tests/unit/proprietary/web_crawler/test_stealth.py b/surfsense_backend/tests/unit/proprietary/web_crawler/test_stealth.py
new file mode 100644
index 000000000..0aa17ef3a
--- /dev/null
+++ b/surfsense_backend/tests/unit/proprietary/web_crawler/test_stealth.py
@@ -0,0 +1,124 @@
+"""Unit tests for the Phase 3e stealth kwargs builder (proprietary boundary)."""
+
+import pytest
+
+from app.config import config
+from app.proprietary.web_crawler import stealth
+from app.proprietary.web_crawler.stealth import (
+ build_stealthy_kwargs,
+ get_stealth_config,
+ location_to_locale_timezone,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def _set_proxy_location(monkeypatch: pytest.MonkeyPatch, location: str) -> None:
+ """Point get_stealth_config at a stub provider with the given exit region."""
+
+ class _StubProvider:
+ def get_location(self) -> str:
+ return location
+
+ monkeypatch.setattr(stealth, "get_active_provider", lambda: _StubProvider())
+
+
+class TestLocationToLocaleTimezone:
+ def test_alpha2_code(self):
+ assert location_to_locale_timezone("us") == ("en-US", "America/New_York")
+ assert location_to_locale_timezone("de") == ("de-DE", "Europe/Berlin")
+
+ def test_case_and_whitespace_insensitive(self):
+ assert location_to_locale_timezone(" US ") == (
+ "en-US",
+ "America/New_York",
+ )
+
+ def test_full_country_name_alias(self):
+ assert location_to_locale_timezone("Germany") == (
+ "de-DE",
+ "Europe/Berlin",
+ )
+ assert location_to_locale_timezone("united kingdom") == (
+ "en-GB",
+ "Europe/London",
+ )
+
+ def test_leading_token_of_vendor_string(self):
+ # Vendor strings like "us:nyc" / "de-rotating" still resolve on the head.
+ assert location_to_locale_timezone("us:nyc") == (
+ "en-US",
+ "America/New_York",
+ )
+ assert location_to_locale_timezone("de-rotating") == (
+ "de-DE",
+ "Europe/Berlin",
+ )
+
+ def test_empty_and_unknown_return_none(self):
+ assert location_to_locale_timezone("") == (None, None)
+ assert location_to_locale_timezone(None) == (None, None)
+ assert location_to_locale_timezone("atlantis") == (None, None)
+
+
+class TestBuildStealthyKwargs:
+ def test_defaults_have_no_geoip_keys(self, monkeypatch):
+ # geoip off => locale/timezone_id absent (browser keeps system default).
+ monkeypatch.setattr(config, "CRAWL_GEOIP_MATCH_ENABLED", False)
+ monkeypatch.setattr(config, "CRAWL_BLOCK_WEBRTC", True)
+ monkeypatch.setattr(config, "CRAWL_HIDE_CANVAS", False)
+ monkeypatch.setattr(config, "CRAWL_GOOGLE_SEARCH_REFERER", True)
+ monkeypatch.setattr(config, "CRAWL_DNS_OVER_HTTPS", False)
+ _set_proxy_location(monkeypatch, "us")
+
+ kwargs = build_stealthy_kwargs(get_stealth_config())
+
+ assert kwargs == {
+ "block_webrtc": True,
+ "hide_canvas": False,
+ "google_search": True,
+ "dns_over_https": False,
+ }
+ assert "locale" not in kwargs
+ assert "timezone_id" not in kwargs
+
+ def test_flags_reflect_config(self, monkeypatch):
+ monkeypatch.setattr(config, "CRAWL_GEOIP_MATCH_ENABLED", False)
+ monkeypatch.setattr(config, "CRAWL_BLOCK_WEBRTC", False)
+ monkeypatch.setattr(config, "CRAWL_HIDE_CANVAS", True)
+ monkeypatch.setattr(config, "CRAWL_GOOGLE_SEARCH_REFERER", False)
+ monkeypatch.setattr(config, "CRAWL_DNS_OVER_HTTPS", True)
+ _set_proxy_location(monkeypatch, "")
+
+ kwargs = build_stealthy_kwargs(get_stealth_config())
+
+ assert kwargs["block_webrtc"] is False
+ assert kwargs["hide_canvas"] is True
+ assert kwargs["google_search"] is False
+ assert kwargs["dns_over_https"] is True
+
+ def test_geoip_on_adds_locale_timezone(self, monkeypatch):
+ monkeypatch.setattr(config, "CRAWL_GEOIP_MATCH_ENABLED", True)
+ monkeypatch.setattr(config, "CRAWL_BLOCK_WEBRTC", True)
+ monkeypatch.setattr(config, "CRAWL_HIDE_CANVAS", False)
+ monkeypatch.setattr(config, "CRAWL_GOOGLE_SEARCH_REFERER", True)
+ monkeypatch.setattr(config, "CRAWL_DNS_OVER_HTTPS", False)
+ _set_proxy_location(monkeypatch, "de")
+
+ kwargs = build_stealthy_kwargs(get_stealth_config())
+
+ assert kwargs["locale"] == "de-DE"
+ assert kwargs["timezone_id"] == "Europe/Berlin"
+
+ def test_geoip_on_but_unknown_location_skips(self, monkeypatch):
+ monkeypatch.setattr(config, "CRAWL_GEOIP_MATCH_ENABLED", True)
+ monkeypatch.setattr(config, "CRAWL_BLOCK_WEBRTC", True)
+ monkeypatch.setattr(config, "CRAWL_HIDE_CANVAS", False)
+ monkeypatch.setattr(config, "CRAWL_GOOGLE_SEARCH_REFERER", True)
+ monkeypatch.setattr(config, "CRAWL_DNS_OVER_HTTPS", False)
+ _set_proxy_location(monkeypatch, "atlantis")
+
+ kwargs = build_stealthy_kwargs(get_stealth_config())
+
+ assert "locale" not in kwargs
+ assert "timezone_id" not in kwargs
diff --git a/surfsense_backend/tests/unit/proprietary/web_crawler/test_url_policy.py b/surfsense_backend/tests/unit/proprietary/web_crawler/test_url_policy.py
new file mode 100644
index 000000000..2c209ce16
--- /dev/null
+++ b/surfsense_backend/tests/unit/proprietary/web_crawler/test_url_policy.py
@@ -0,0 +1,130 @@
+"""Pure URL-helper behavior for the site spider (link surfacing + dedupe scope)."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.proprietary.web_crawler.url_policy import (
+ extract_link_records,
+ extract_links,
+ host_of,
+)
+
+pytestmark = pytest.mark.unit
+
+_HTML = """
+
+ About (relative)
+ Contact (absolute)
+ External
+ Anchor only
+ Mail
+ JS
+ Duplicate about
+
+"""
+
+
+def test_extract_links_absolutizes_relative_hrefs() -> None:
+ links = extract_links(_HTML, "https://example.com/home")
+ assert "https://example.com/about" in links
+ assert "https://example.com/contact" in links
+
+
+def test_extract_links_keeps_only_http_schemes() -> None:
+ links = extract_links(_HTML, "https://example.com/home")
+ assert all(link.startswith(("http://", "https://")) for link in links)
+ assert not any("mailto" in link or "javascript" in link for link in links)
+
+
+def test_extract_links_drops_pure_anchor() -> None:
+ links = extract_links(_HTML, "https://example.com/home")
+ assert "https://example.com/home" not in links # bare "#top" resolves to self
+
+
+def test_extract_links_dedupes_preserving_first_occurrence() -> None:
+ links = extract_links(_HTML, "https://example.com/home")
+ assert links.count("https://example.com/about") == 1
+
+
+def test_extract_links_strips_fragments() -> None:
+ assert extract_links('x ', "https://e.com") == [
+ "https://e.com/p"
+ ]
+
+
+def test_extract_links_on_empty_or_blank_html_is_empty() -> None:
+ assert extract_links("", "https://e.com") == []
+ assert extract_links(" ", "https://e.com") == []
+ assert extract_links(None, "https://e.com") == []
+
+
+def test_extract_link_records_classifies_kinds_and_keeps_anchor_text() -> None:
+ html = """
+ About\n us
+ External
+ Jane Doe
+ Mail
+ Call
+ """
+ records = {r["url"]: r for r in extract_link_records(html, "https://example.com/")}
+ assert records["https://example.com/about"]["kind"] == "internal"
+ assert records["https://example.com/about"]["text"] == "About us" # collapsed ws
+ assert records["https://other.com/x"]["kind"] == "external"
+ assert records["https://www.linkedin.com/in/jane"] == {
+ "url": "https://www.linkedin.com/in/jane",
+ "text": "Jane Doe",
+ "context": "",
+ "rel": "",
+ "kind": "social",
+ }
+ assert records["a@b.com"]["kind"] == "email" # mailto query stripped
+ assert records["+1-555-0100"]["kind"] == "tel"
+
+
+def test_percent_encoded_tel_and_mailto_are_decoded() -> None:
+ """Seen live: must not leak %20."""
+ html = """
+ Call
+ Email
+ """
+ records = {r["kind"]: r for r in extract_link_records(html, "https://example.com/")}
+ assert records["tel"]["url"] == "+1 408-629-1770"
+ assert records["email"]["url"] == "hello@acme.io"
+
+
+def test_icon_only_social_link_gets_ancestor_context() -> None:
+ html = """
+
+
Jane Doe General Partner
+
+
+ """
+ (record,) = extract_link_records(html, "https://example.com/")
+ assert record["text"] == ""
+ assert record["context"] == "Jane Doe General Partner"
+
+
+def test_icon_social_link_prefers_aria_label_over_context() -> None:
+ html = ''
+ (record,) = extract_link_records(html, "https://example.com/")
+ assert record["text"] == "Acme on X"
+ assert record["context"] == ""
+
+
+def test_extract_link_records_dedupes_keeping_first_nonempty_text() -> None:
+ html = 'Pricing '
+ records = extract_link_records(html, "https://example.com/")
+ assert records == [
+ {
+ "url": "https://example.com/p",
+ "text": "Pricing",
+ "rel": "",
+ "kind": "internal",
+ }
+ ]
+
+
+def test_host_of_strips_www_and_lowercases() -> None:
+ assert host_of("https://www.Example.com/x") == "example.com"
+ assert host_of("https://Example.com/x") == "example.com"
diff --git a/surfsense_backend/tests/unit/routes/test_image_gen_quota.py b/surfsense_backend/tests/unit/routes/test_image_gen_quota.py
index 3d94c6c51..b54309dfe 100644
--- a/surfsense_backend/tests/unit/routes/test_image_gen_quota.py
+++ b/surfsense_backend/tests/unit/routes/test_image_gen_quota.py
@@ -36,11 +36,11 @@ async def test_resolve_billing_for_auto_mode(monkeypatch):
_no_auto_candidates,
)
- search_space = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
+ workspace = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
tier, model, reserve = await image_generation_routes._resolve_billing_for_image_gen(
session=None,
config_id=0, # IMAGE_GEN_AUTO_MODE_ID
- search_space=search_space,
+ workspace=workspace,
)
assert tier == "free"
assert model == "auto"
@@ -95,11 +95,11 @@ async def test_resolve_billing_for_premium_global_config(monkeypatch):
raising=False,
)
- search_space = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
+ workspace = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
# Premium with override.
tier, model, reserve = await image_generation_routes._resolve_billing_for_image_gen(
- session=None, config_id=-1, search_space=search_space
+ session=None, config_id=-1, workspace=workspace
)
assert tier == "premium"
assert model == "openai/gpt-image-1"
@@ -109,7 +109,7 @@ async def test_resolve_billing_for_premium_global_config(monkeypatch):
from app.services.billable_calls import DEFAULT_IMAGE_RESERVE_MICROS
tier, model, reserve = await image_generation_routes._resolve_billing_for_image_gen(
- session=None, config_id=-2, search_space=search_space
+ session=None, config_id=-2, workspace=workspace
)
assert tier == "free"
# Provider-prefixed model string for OpenRouter.
@@ -125,9 +125,9 @@ async def test_resolve_billing_for_user_owned_byok_is_free():
from app.routes import image_generation_routes
from app.services.billable_calls import DEFAULT_IMAGE_RESERVE_MICROS
- search_space = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
+ workspace = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
tier, model, reserve = await image_generation_routes._resolve_billing_for_image_gen(
- session=None, config_id=42, search_space=search_space
+ session=None, config_id=42, workspace=workspace
)
assert tier == "free"
assert model == "user_byok"
@@ -135,9 +135,9 @@ async def test_resolve_billing_for_user_owned_byok_is_free():
@pytest.mark.asyncio
-async def test_resolve_billing_falls_back_to_search_space_default(monkeypatch):
+async def test_resolve_billing_falls_back_to_workspace_default(monkeypatch):
"""When the request omits ``image_gen_model_id``, the helper
- must consult the search space's default — so a search space pinned
+ must consult the workspace's default — so a workspace pinned
to a premium global config still gates new requests by quota.
"""
from app.config import config
@@ -172,13 +172,13 @@ async def test_resolve_billing_falls_back_to_search_space_default(monkeypatch):
raising=False,
)
- search_space = SimpleNamespace(id=1, user_id=None, image_gen_model_id=-7)
+ workspace = SimpleNamespace(id=1, user_id=None, image_gen_model_id=-7)
(
tier,
model,
_reserve,
) = await image_generation_routes._resolve_billing_for_image_gen(
- session=None, config_id=None, search_space=search_space
+ session=None, config_id=None, workspace=workspace
)
assert tier == "premium"
assert model == "openai/gpt-image-1"
diff --git a/surfsense_backend/tests/unit/routes/test_regenerate_from_message_id.py b/surfsense_backend/tests/unit/routes/test_regenerate_from_message_id.py
index 709014d55..2209628de 100644
--- a/surfsense_backend/tests/unit/routes/test_regenerate_from_message_id.py
+++ b/surfsense_backend/tests/unit/routes/test_regenerate_from_message_id.py
@@ -117,7 +117,7 @@ class TestRegenerateRequestValidation:
def test_revert_actions_requires_from_message_id(self) -> None:
with pytest.raises(Exception) as exc:
RegenerateRequest(
- search_space_id=1,
+ workspace_id=1,
user_query="hi",
revert_actions=True,
)
@@ -126,7 +126,7 @@ class TestRegenerateRequestValidation:
def test_from_message_id_without_revert_is_allowed(self) -> None:
req = RegenerateRequest(
- search_space_id=1,
+ workspace_id=1,
user_query="hi",
from_message_id=42,
)
@@ -135,7 +135,7 @@ class TestRegenerateRequestValidation:
def test_revert_actions_with_from_message_id_passes(self) -> None:
req = RegenerateRequest(
- search_space_id=1,
+ workspace_id=1,
user_query="hi",
from_message_id=42,
revert_actions=True,
diff --git a/surfsense_backend/tests/unit/services/test_agent_billing_resolver.py b/surfsense_backend/tests/unit/services/test_agent_billing_resolver.py
index b43540ba7..da486b77c 100644
--- a/surfsense_backend/tests/unit/services/test_agent_billing_resolver.py
+++ b/surfsense_backend/tests/unit/services/test_agent_billing_resolver.py
@@ -1,4 +1,4 @@
-"""Unit tests for ``_resolve_agent_billing_for_search_space``."""
+"""Unit tests for ``_resolve_agent_billing_for_workspace``."""
from __future__ import annotations
@@ -39,7 +39,7 @@ class _FakePinResolution:
from_existing_pin: bool = False
-def _make_search_space(*, chat_model_id: int | None, user_id: UUID) -> SimpleNamespace:
+def _make_workspace(*, chat_model_id: int | None, user_id: UUID) -> SimpleNamespace:
return SimpleNamespace(id=42, chat_model_id=chat_model_id, user_id=user_id)
@@ -50,16 +50,16 @@ def _make_byok_model(
id=id_,
model_id=model_id,
catalog={"base_model": base_model} if base_model else {},
- connection=SimpleNamespace(enabled=True, search_space_id=42, user_id=None),
+ connection=SimpleNamespace(enabled=True, workspace_id=42, user_id=None),
)
@pytest.mark.asyncio
async def test_auto_mode_with_thread_id_resolves_to_premium_global(monkeypatch):
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- session = _FakeSession([_make_search_space(chat_model_id=0, user_id=user_id)])
+ session = _FakeSession([_make_workspace(chat_model_id=0, user_id=user_id)])
async def _fake_resolve_pin(*_args, **kwargs):
assert kwargs["selected_llm_config_id"] == 0
@@ -84,8 +84,8 @@ async def test_auto_mode_with_thread_id_resolves_to_premium_global(monkeypatch):
)
monkeypatch.setattr(llm_module, "get_global_llm_config", _fake_get_global)
- owner, tier, base_model = await _resolve_agent_billing_for_search_space(
- session, search_space_id=42, thread_id=99
+ owner, tier, base_model = await _resolve_agent_billing_for_workspace(
+ session, workspace_id=42, thread_id=99
)
assert owner == user_id
@@ -95,14 +95,14 @@ async def test_auto_mode_with_thread_id_resolves_to_premium_global(monkeypatch):
@pytest.mark.asyncio
async def test_auto_mode_with_thread_id_resolves_to_byok_is_free(monkeypatch):
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- search_space = _make_search_space(chat_model_id=0, user_id=user_id)
+ workspace = _make_workspace(chat_model_id=0, user_id=user_id)
byok_model = _make_byok_model(
id_=17, base_model="anthropic/claude-3-haiku", model_id="my-claude"
)
- session = _FakeSession([search_space, byok_model])
+ session = _FakeSession([workspace, byok_model])
async def _fake_resolve_pin(*_args, **_kwargs):
return _FakePinResolution(resolved_llm_config_id=17, resolved_tier="free")
@@ -113,8 +113,8 @@ async def test_auto_mode_with_thread_id_resolves_to_byok_is_free(monkeypatch):
pin_module, "resolve_or_get_pinned_llm_config_id", _fake_resolve_pin
)
- owner, tier, base_model = await _resolve_agent_billing_for_search_space(
- session, search_space_id=42, thread_id=99
+ owner, tier, base_model = await _resolve_agent_billing_for_workspace(
+ session, workspace_id=42, thread_id=99
)
assert owner == user_id
@@ -124,13 +124,13 @@ async def test_auto_mode_with_thread_id_resolves_to_byok_is_free(monkeypatch):
@pytest.mark.asyncio
async def test_auto_mode_without_thread_id_falls_back_to_free():
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- session = _FakeSession([_make_search_space(chat_model_id=0, user_id=user_id)])
+ session = _FakeSession([_make_workspace(chat_model_id=0, user_id=user_id)])
- owner, tier, base_model = await _resolve_agent_billing_for_search_space(
- session, search_space_id=42, thread_id=None
+ owner, tier, base_model = await _resolve_agent_billing_for_workspace(
+ session, workspace_id=42, thread_id=None
)
assert owner == user_id
@@ -140,10 +140,10 @@ async def test_auto_mode_without_thread_id_falls_back_to_free():
@pytest.mark.asyncio
async def test_auto_mode_pin_failure_falls_back_to_free(monkeypatch):
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- session = _FakeSession([_make_search_space(chat_model_id=0, user_id=user_id)])
+ session = _FakeSession([_make_workspace(chat_model_id=0, user_id=user_id)])
async def _fake_resolve_pin(*args, **kwargs):
raise ValueError("thread missing")
@@ -154,8 +154,8 @@ async def test_auto_mode_pin_failure_falls_back_to_free(monkeypatch):
pin_module, "resolve_or_get_pinned_llm_config_id", _fake_resolve_pin
)
- owner, tier, base_model = await _resolve_agent_billing_for_search_space(
- session, search_space_id=42, thread_id=99
+ owner, tier, base_model = await _resolve_agent_billing_for_workspace(
+ session, workspace_id=42, thread_id=99
)
assert owner == user_id
@@ -165,10 +165,10 @@ async def test_auto_mode_pin_failure_falls_back_to_free(monkeypatch):
@pytest.mark.asyncio
async def test_negative_id_premium_global_returns_premium(monkeypatch):
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- session = _FakeSession([_make_search_space(chat_model_id=-1, user_id=user_id)])
+ session = _FakeSession([_make_workspace(chat_model_id=-1, user_id=user_id)])
def _fake_get_global(cfg_id):
return {
@@ -182,8 +182,8 @@ async def test_negative_id_premium_global_returns_premium(monkeypatch):
monkeypatch.setattr(llm_module, "get_global_llm_config", _fake_get_global)
- owner, tier, base_model = await _resolve_agent_billing_for_search_space(
- session, search_space_id=42, thread_id=99
+ owner, tier, base_model = await _resolve_agent_billing_for_workspace(
+ session, workspace_id=42, thread_id=99
)
assert owner == user_id
@@ -193,10 +193,10 @@ async def test_negative_id_premium_global_returns_premium(monkeypatch):
@pytest.mark.asyncio
async def test_negative_id_missing_base_model_falls_back_to_model_name(monkeypatch):
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- session = _FakeSession([_make_search_space(chat_model_id=-5, user_id=user_id)])
+ session = _FakeSession([_make_workspace(chat_model_id=-5, user_id=user_id)])
def _fake_get_global(cfg_id):
return {"id": cfg_id, "model_name": "fallback-model", "billing_tier": "premium"}
@@ -205,8 +205,8 @@ async def test_negative_id_missing_base_model_falls_back_to_model_name(monkeypat
monkeypatch.setattr(llm_module, "get_global_llm_config", _fake_get_global)
- _, tier, base_model = await _resolve_agent_billing_for_search_space(
- session, search_space_id=42
+ _, tier, base_model = await _resolve_agent_billing_for_workspace(
+ session, workspace_id=42
)
assert tier == "premium"
@@ -215,15 +215,15 @@ async def test_negative_id_missing_base_model_falls_back_to_model_name(monkeypat
@pytest.mark.asyncio
async def test_positive_id_byok_is_always_free():
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- search_space = _make_search_space(chat_model_id=23, user_id=user_id)
+ workspace = _make_workspace(chat_model_id=23, user_id=user_id)
byok_model = _make_byok_model(id_=23, base_model="anthropic/claude-3.5-sonnet")
- session = _FakeSession([search_space, byok_model])
+ session = _FakeSession([workspace, byok_model])
- owner, tier, base_model = await _resolve_agent_billing_for_search_space(
- session, search_space_id=42
+ owner, tier, base_model = await _resolve_agent_billing_for_workspace(
+ session, workspace_id=42
)
assert owner == user_id
@@ -233,13 +233,13 @@ async def test_positive_id_byok_is_always_free():
@pytest.mark.asyncio
async def test_positive_id_byok_missing_returns_free_with_empty_base_model():
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- session = _FakeSession([_make_search_space(chat_model_id=99, user_id=user_id)])
+ session = _FakeSession([_make_workspace(chat_model_id=99, user_id=user_id)])
- owner, tier, base_model = await _resolve_agent_billing_for_search_space(
- session, search_space_id=42
+ owner, tier, base_model = await _resolve_agent_billing_for_workspace(
+ session, workspace_id=42
)
assert owner == user_id
@@ -248,21 +248,21 @@ async def test_positive_id_byok_missing_returns_free_with_empty_base_model():
@pytest.mark.asyncio
-async def test_search_space_not_found_raises_value_error():
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+async def test_workspace_not_found_raises_value_error():
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
- with pytest.raises(ValueError, match="Search space"):
- await _resolve_agent_billing_for_search_space(
- _FakeSession([None]), search_space_id=999
+ with pytest.raises(ValueError, match="Workspace"):
+ await _resolve_agent_billing_for_workspace(
+ _FakeSession([None]), workspace_id=999
)
@pytest.mark.asyncio
async def test_chat_model_id_none_raises_value_error():
- from app.services.billable_calls import _resolve_agent_billing_for_search_space
+ from app.services.billable_calls import _resolve_agent_billing_for_workspace
user_id = uuid4()
- session = _FakeSession([_make_search_space(chat_model_id=None, user_id=user_id)])
+ session = _FakeSession([_make_workspace(chat_model_id=None, user_id=user_id)])
with pytest.raises(ValueError, match="chat_model_id"):
- await _resolve_agent_billing_for_search_space(session, search_space_id=42)
+ await _resolve_agent_billing_for_workspace(session, workspace_id=42)
diff --git a/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py b/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py
deleted file mode 100644
index 860c2ffa2..000000000
--- a/surfsense_backend/tests/unit/services/test_ai_file_sort_service.py
+++ /dev/null
@@ -1,275 +0,0 @@
-"""Unit tests for AI file sort service: folder label resolution, date extraction, category sanitization."""
-
-from datetime import UTC, datetime
-from unittest.mock import AsyncMock, MagicMock
-
-import pytest
-
-pytestmark = pytest.mark.unit
-
-
-# ── resolve_root_folder_label ──
-
-
-def _make_document(document_type: str, connector_id=None):
- doc = MagicMock()
- doc.document_type = document_type
- doc.connector_id = connector_id
- return doc
-
-
-def _make_connector(connector_type: str):
- conn = MagicMock()
- conn.connector_type = connector_type
- return conn
-
-
-def test_root_label_uses_connector_type_when_available():
- from app.services.ai_file_sort_service import resolve_root_folder_label
-
- doc = _make_document("FILE", connector_id=1)
- conn = _make_connector("GOOGLE_DRIVE_CONNECTOR")
- assert resolve_root_folder_label(doc, conn) == "Google Drive"
-
-
-def test_root_label_falls_back_to_document_type():
- from app.services.ai_file_sort_service import resolve_root_folder_label
-
- doc = _make_document("SLACK_CONNECTOR")
- assert resolve_root_folder_label(doc, None) == "Slack"
-
-
-def test_root_label_unknown_doctype_returns_raw_value():
- from app.services.ai_file_sort_service import resolve_root_folder_label
-
- doc = _make_document("UNKNOWN_TYPE")
- assert resolve_root_folder_label(doc, None) == "UNKNOWN_TYPE"
-
-
-# ── resolve_date_folder ──
-
-
-def test_date_folder_from_updated_at():
- from app.services.ai_file_sort_service import resolve_date_folder
-
- doc = MagicMock()
- doc.updated_at = datetime(2025, 3, 15, 10, 30, 0, tzinfo=UTC)
- doc.created_at = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
- assert resolve_date_folder(doc) == "2025-03-15"
-
-
-def test_date_folder_falls_back_to_created_at():
- from app.services.ai_file_sort_service import resolve_date_folder
-
- doc = MagicMock()
- doc.updated_at = None
- doc.created_at = datetime(2024, 12, 25, 23, 59, 0, tzinfo=UTC)
- assert resolve_date_folder(doc) == "2024-12-25"
-
-
-def test_date_folder_both_none_uses_today():
- from app.services.ai_file_sort_service import resolve_date_folder
-
- doc = MagicMock()
- doc.updated_at = None
- doc.created_at = None
- result = resolve_date_folder(doc)
- today = datetime.now(UTC).strftime("%Y-%m-%d")
- assert result == today
-
-
-# ── sanitize_category_folder_name ──
-
-
-def test_sanitize_normal_value():
- from app.services.ai_file_sort_service import sanitize_category_folder_name
-
- assert sanitize_category_folder_name("Machine Learning") == "Machine Learning"
-
-
-def test_sanitize_strips_special_chars():
- from app.services.ai_file_sort_service import sanitize_category_folder_name
-
- assert sanitize_category_folder_name("Tax/Reports!") == "TaxReports"
-
-
-def test_sanitize_empty_returns_fallback():
- from app.services.ai_file_sort_service import sanitize_category_folder_name
-
- assert sanitize_category_folder_name("") == "Uncategorized"
- assert sanitize_category_folder_name(None) == "Uncategorized"
-
-
-def test_sanitize_truncates_long_names():
- from app.services.ai_file_sort_service import sanitize_category_folder_name
-
- long_name = "A" * 100
- result = sanitize_category_folder_name(long_name)
- assert len(result) <= 50
-
-
-# ── generate_ai_taxonomy ──
-
-
-@pytest.mark.asyncio
-async def test_generate_ai_taxonomy_parses_json():
- from app.services.ai_file_sort_service import generate_ai_taxonomy
-
- mock_llm = AsyncMock()
- mock_result = MagicMock()
- mock_result.content = '{"category": "Science", "subcategory": "Physics"}'
- mock_llm.ainvoke.return_value = mock_result
-
- cat, sub = await generate_ai_taxonomy(
- "Physics Paper", "Some science document about physics", mock_llm
- )
- assert cat == "Science"
- assert sub == "Physics"
-
-
-@pytest.mark.asyncio
-async def test_generate_ai_taxonomy_handles_markdown_code_block():
- from app.services.ai_file_sort_service import generate_ai_taxonomy
-
- mock_llm = AsyncMock()
- mock_result = MagicMock()
- mock_result.content = (
- '```json\n{"category": "Finance", "subcategory": "Tax Reports"}\n```'
- )
- mock_llm.ainvoke.return_value = mock_result
-
- cat, sub = await generate_ai_taxonomy("Tax Doc", "A tax report document", mock_llm)
- assert cat == "Finance"
- assert sub == "Tax Reports"
-
-
-@pytest.mark.asyncio
-async def test_generate_ai_taxonomy_includes_title_in_prompt():
- from app.services.ai_file_sort_service import generate_ai_taxonomy
-
- mock_llm = AsyncMock()
- mock_result = MagicMock()
- mock_result.content = '{"category": "Engineering", "subcategory": "Backend"}'
- mock_llm.ainvoke.return_value = mock_result
-
- await generate_ai_taxonomy("API Design Guide", "content about REST APIs", mock_llm)
-
- prompt_text = mock_llm.ainvoke.call_args[0][0][0].content
- assert "API Design Guide" in prompt_text
- assert "content about REST APIs" in prompt_text
-
-
-@pytest.mark.asyncio
-async def test_generate_ai_taxonomy_fallback_on_error():
- from app.services.ai_file_sort_service import generate_ai_taxonomy
-
- mock_llm = AsyncMock()
- mock_llm.ainvoke.side_effect = RuntimeError("LLM down")
-
- cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm)
- assert cat == "Uncategorized"
- assert sub == "General"
-
-
-@pytest.mark.asyncio
-async def test_generate_ai_taxonomy_fallback_on_empty_content():
- from app.services.ai_file_sort_service import generate_ai_taxonomy
-
- mock_llm = AsyncMock()
- cat, sub = await generate_ai_taxonomy("Title", "", mock_llm)
- assert cat == "Uncategorized"
- assert sub == "General"
- mock_llm.ainvoke.assert_not_called()
-
-
-@pytest.mark.asyncio
-async def test_generate_ai_taxonomy_fallback_on_invalid_json():
- from app.services.ai_file_sort_service import generate_ai_taxonomy
-
- mock_llm = AsyncMock()
- mock_result = MagicMock()
- mock_result.content = "not valid json at all"
- mock_llm.ainvoke.return_value = mock_result
-
- cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm)
- assert cat == "Uncategorized"
- assert sub == "General"
-
-
-# ── taxonomy caching ──
-
-
-def test_get_cached_taxonomy_returns_none_when_no_metadata():
- from app.services.ai_file_sort_service import _get_cached_taxonomy
-
- doc = MagicMock()
- doc.document_metadata = None
- assert _get_cached_taxonomy(doc) is None
-
-
-def test_get_cached_taxonomy_returns_none_when_keys_missing():
- from app.services.ai_file_sort_service import _get_cached_taxonomy
-
- doc = MagicMock()
- doc.document_metadata = {"some_other_key": "value"}
- assert _get_cached_taxonomy(doc) is None
-
-
-def test_get_cached_taxonomy_returns_cached_values():
- from app.services.ai_file_sort_service import _get_cached_taxonomy
-
- doc = MagicMock()
- doc.document_metadata = {
- "ai_sort_category": "Finance",
- "ai_sort_subcategory": "Tax Reports",
- }
- assert _get_cached_taxonomy(doc) == ("Finance", "Tax Reports")
-
-
-def test_set_cached_taxonomy_persists_on_metadata():
- from app.services.ai_file_sort_service import _set_cached_taxonomy
-
- doc = MagicMock()
- doc.document_metadata = {"existing_key": "keep_me"}
- _set_cached_taxonomy(doc, "Science", "Physics")
- assert doc.document_metadata["ai_sort_category"] == "Science"
- assert doc.document_metadata["ai_sort_subcategory"] == "Physics"
- assert doc.document_metadata["existing_key"] == "keep_me"
-
-
-def test_set_cached_taxonomy_creates_metadata_when_none():
- from app.services.ai_file_sort_service import _set_cached_taxonomy
-
- doc = MagicMock()
- doc.document_metadata = None
- _set_cached_taxonomy(doc, "Engineering", "Backend")
- assert doc.document_metadata == {
- "ai_sort_category": "Engineering",
- "ai_sort_subcategory": "Backend",
- }
-
-
-# ── _build_path_segments ──
-
-
-def test_build_path_segments_structure():
- from app.services.ai_file_sort_service import _build_path_segments
-
- segments = _build_path_segments("Google Drive", "2025-03-15", "Science", "Physics")
- assert len(segments) == 4
- assert segments[0] == {
- "name": "Google Drive",
- "metadata": {"ai_sort": True, "ai_sort_level": 1},
- }
- assert segments[1] == {
- "name": "2025-03-15",
- "metadata": {"ai_sort": True, "ai_sort_level": 2},
- }
- assert segments[2] == {
- "name": "Science",
- "metadata": {"ai_sort": True, "ai_sort_level": 3},
- }
- assert segments[3] == {
- "name": "Physics",
- "metadata": {"ai_sort": True, "ai_sort_level": 4},
- }
diff --git a/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py b/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py
deleted file mode 100644
index fd9018514..000000000
--- a/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Unit tests for AI sort task Redis deduplication lock."""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-pytestmark = pytest.mark.unit
-
-
-def test_lock_key_format():
- from app.tasks.celery_tasks.document_tasks import _ai_sort_lock_key
-
- key = _ai_sort_lock_key(42)
- assert key == "ai_sort:search_space:42:lock"
-
-
-def test_lock_prevents_duplicate_run():
- """When the Redis lock already exists, the task should skip execution."""
-
- mock_redis = MagicMock()
- mock_redis.set.return_value = False # Lock already held
-
- with (
- patch(
- "app.tasks.celery_tasks.document_tasks._get_ai_sort_redis",
- return_value=mock_redis,
- ),
- patch(
- "app.tasks.celery_tasks.document_tasks.get_celery_session_maker"
- ) as mock_session_maker,
- ):
- import asyncio
-
- from app.tasks.celery_tasks.document_tasks import _ai_sort_search_space_async
-
- loop = asyncio.new_event_loop()
- try:
- loop.run_until_complete(_ai_sort_search_space_async(1, "user-123"))
- finally:
- loop.close()
-
- # Session maker should never be called since lock was not acquired
- mock_session_maker.assert_not_called()
diff --git a/surfsense_backend/tests/unit/services/test_auto_model_pin_service.py b/surfsense_backend/tests/unit/services/test_auto_model_pin_service.py
index 598e9b1ab..909fcbd95 100644
--- a/surfsense_backend/tests/unit/services/test_auto_model_pin_service.py
+++ b/surfsense_backend/tests/unit/services/test_auto_model_pin_service.py
@@ -140,12 +140,12 @@ def _set_global_llm_configs(monkeypatch, config, configs: list[dict]):
def _thread(
*,
- search_space_id: int = 10,
+ workspace_id: int = 10,
pinned_llm_config_id: int | None = None,
):
return SimpleNamespace(
id=1,
- search_space_id=search_space_id,
+ workspace_id=workspace_id,
pinned_llm_config_id=pinned_llm_config_id,
)
@@ -186,7 +186,7 @@ async def test_auto_first_turn_pins_one_model(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -234,7 +234,7 @@ async def test_premium_eligible_auto_prefers_premium_over_free(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -321,7 +321,7 @@ async def test_premium_eligible_auto_uses_quality_pool_not_single_preferred_mode
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -361,7 +361,7 @@ async def test_next_turn_reuses_existing_pin(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -400,7 +400,7 @@ async def test_premium_eligible_auto_can_pin_premium(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -445,7 +445,7 @@ async def test_premium_ineligible_auto_pins_free_only(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -490,7 +490,7 @@ async def test_pinned_premium_stays_premium_after_quota_exhaustion(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -535,7 +535,7 @@ async def test_force_repin_free_switches_auto_premium_pin_to_free(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
force_repin_free=True,
@@ -567,7 +567,7 @@ async def test_explicit_user_model_change_clears_pin(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=7,
)
@@ -605,7 +605,7 @@ async def test_invalid_pinned_config_repairs_with_new_pin(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -664,7 +664,7 @@ async def test_health_gated_config_is_excluded_from_selection(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -716,7 +716,7 @@ async def test_tier_a_locks_first_premium_user_skips_or(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -768,7 +768,7 @@ async def test_tier_a_falls_through_to_or_when_a_pool_empty_for_user(monkeypatch
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -825,7 +825,7 @@ async def test_top_k_picks_only_high_score_models(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=thread_id,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -889,7 +889,7 @@ async def test_pin_reuse_survives_health_gating_for_existing_pin(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -941,7 +941,7 @@ async def test_pin_reuse_regression_existing_healthy_pin(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -1001,7 +1001,7 @@ async def test_runtime_cooled_down_pin_is_not_reused(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -1069,7 +1069,7 @@ async def test_shared_runtime_cooldown_blocks_pin_across_workers(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -1113,7 +1113,7 @@ async def test_clearing_runtime_cooldown_restores_pin_reuse(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
)
@@ -1165,7 +1165,7 @@ async def test_auto_pin_repin_excludes_previous_config_on_runtime_retry(monkeypa
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id="00000000-0000-0000-0000-000000000001",
selected_llm_config_id=0,
exclude_config_ids={-1},
diff --git a/surfsense_backend/tests/unit/services/test_auto_pin_image_aware.py b/surfsense_backend/tests/unit/services/test_auto_pin_image_aware.py
index c1e66feb9..993a0e439 100644
--- a/surfsense_backend/tests/unit/services/test_auto_pin_image_aware.py
+++ b/surfsense_backend/tests/unit/services/test_auto_pin_image_aware.py
@@ -76,7 +76,7 @@ class _FakeSession:
def _thread(*, pinned: int | None = None):
- return SimpleNamespace(id=1, search_space_id=10, pinned_llm_config_id=pinned)
+ return SimpleNamespace(id=1, workspace_id=10, pinned_llm_config_id=pinned)
def _set_global_llm_configs(monkeypatch, config, configs: list[dict]):
@@ -179,7 +179,7 @@ async def test_image_turn_filters_out_text_only_candidates(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id=None,
selected_llm_config_id=0,
requires_image_input=True,
@@ -207,7 +207,7 @@ async def test_image_turn_force_repins_stale_text_only_pin(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id=None,
selected_llm_config_id=0,
requires_image_input=True,
@@ -239,7 +239,7 @@ async def test_image_turn_reuses_existing_vision_pin(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id=None,
selected_llm_config_id=0,
requires_image_input=True,
@@ -269,7 +269,7 @@ async def test_image_turn_with_no_vision_candidates_raises(monkeypatch):
await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id=None,
selected_llm_config_id=0,
requires_image_input=True,
@@ -292,7 +292,7 @@ async def test_non_image_turn_keeps_text_only_in_pool(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id=None,
selected_llm_config_id=0,
)
@@ -326,7 +326,7 @@ async def test_image_turn_unannotated_cfg_resolves_via_helper(monkeypatch):
result = await resolve_or_get_pinned_llm_config_id(
session,
thread_id=1,
- search_space_id=10,
+ workspace_id=10,
user_id=None,
selected_llm_config_id=0,
requires_image_input=True,
diff --git a/surfsense_backend/tests/unit/services/test_billable_call.py b/surfsense_backend/tests/unit/services/test_billable_call.py
index 8e2c2f1da..a117037bf 100644
--- a/surfsense_backend/tests/unit/services/test_billable_call.py
+++ b/surfsense_backend/tests/unit/services/test_billable_call.py
@@ -169,7 +169,7 @@ async def test_free_path_skips_reserve_but_writes_audit_row(monkeypatch):
async with billable_call(
user_id=user_id,
- search_space_id=42,
+ workspace_id=42,
billing_tier="free",
base_model="openai/gpt-image-1",
usage_type="image_generation",
@@ -210,7 +210,7 @@ async def test_premium_reserve_denied_raises_quota_insufficient(monkeypatch):
with pytest.raises(QuotaInsufficientError) as exc_info:
async with billable_call(
user_id=user_id,
- search_space_id=42,
+ workspace_id=42,
billing_tier="premium",
base_model="openai/gpt-image-1",
quota_reserve_micros_override=50_000,
@@ -242,7 +242,7 @@ async def test_premium_success_finalizes_with_actual_cost(monkeypatch):
async with billable_call(
user_id=user_id,
- search_space_id=42,
+ workspace_id=42,
billing_tier="premium",
base_model="openai/gpt-image-1",
quota_reserve_micros_override=50_000,
@@ -292,7 +292,7 @@ async def test_premium_failure_releases_reservation(monkeypatch):
with pytest.raises(_ProviderError):
async with billable_call(
user_id=user_id,
- search_space_id=42,
+ workspace_id=42,
billing_tier="premium",
base_model="openai/gpt-image-1",
quota_reserve_micros_override=50_000,
@@ -336,7 +336,7 @@ async def test_premium_uses_estimator_when_no_micros_override(monkeypatch):
user_id = uuid4()
async with billable_call(
user_id=user_id,
- search_space_id=1,
+ workspace_id=1,
billing_tier="premium",
base_model="openai/gpt-4o",
quota_reserve_tokens=4000,
@@ -367,7 +367,7 @@ async def test_premium_finalize_failure_propagates_and_releases(monkeypatch):
with pytest.raises(BillingSettlementError):
async with billable_call(
user_id=user_id,
- search_space_id=42,
+ workspace_id=42,
billing_tier="premium",
base_model="openai/gpt-image-1",
quota_reserve_micros_override=50_000,
@@ -408,7 +408,7 @@ async def test_premium_audit_commit_hang_times_out_after_finalize(monkeypatch):
async with billable_call(
user_id=user_id,
- search_space_id=42,
+ workspace_id=42,
billing_tier="premium",
base_model="openai/gpt-image-1",
quota_reserve_micros_override=50_000,
@@ -450,7 +450,7 @@ async def test_free_audit_failure_is_best_effort(monkeypatch):
async with billable_call(
user_id=uuid4(),
- search_space_id=42,
+ workspace_id=42,
billing_tier="free",
base_model="openai/gpt-image-1",
usage_type="image_generation",
@@ -488,7 +488,7 @@ async def test_free_podcast_path_audits_with_podcast_usage_type(monkeypatch):
async with billable_call(
user_id=user_id,
- search_space_id=42,
+ workspace_id=42,
billing_tier="free",
base_model="openrouter/some-free-model",
quota_reserve_micros_override=200_000,
@@ -514,7 +514,7 @@ async def test_free_podcast_path_audits_with_podcast_usage_type(monkeypatch):
row = spies["record"][0]
assert row["usage_type"] == "podcast_generation"
assert row["thread_id"] is None
- assert row["search_space_id"] == 42
+ assert row["workspace_id"] == 42
assert row["call_details"] == {"podcast_id": 7, "title": "Test Podcast"}
@@ -539,7 +539,7 @@ async def test_premium_video_denial_raises_quota_insufficient(monkeypatch):
with pytest.raises(QuotaInsufficientError) as exc_info:
async with billable_call(
user_id=user_id,
- search_space_id=42,
+ workspace_id=42,
billing_tier="premium",
base_model="gpt-5.4",
quota_reserve_micros_override=1_000_000,
diff --git a/surfsense_backend/tests/unit/services/test_folder_hierarchy.py b/surfsense_backend/tests/unit/services/test_folder_hierarchy.py
index 9077f6b0e..d444b1a7d 100644
--- a/surfsense_backend/tests/unit/services/test_folder_hierarchy.py
+++ b/surfsense_backend/tests/unit/services/test_folder_hierarchy.py
@@ -49,8 +49,8 @@ async def test_creates_missing_folders_in_chain():
session.flush = mock_flush
segments = [
- {"name": "Slack", "metadata": {"ai_sort": True, "ai_sort_level": 1}},
- {"name": "2025-03-15", "metadata": {"ai_sort": True, "ai_sort_level": 2}},
+ {"name": "Slack", "metadata": {"source": "slack"}},
+ {"name": "2025-03-15", "metadata": {"source": "slack"}},
]
result = await ensure_folder_hierarchy_with_depth_validation(
diff --git a/surfsense_backend/tests/unit/services/test_image_gen_api_base_defense.py b/surfsense_backend/tests/unit/services/test_image_gen_api_base_defense.py
index adcfeed48..d6cc7ba2a 100644
--- a/surfsense_backend/tests/unit/services/test_image_gen_api_base_defense.py
+++ b/surfsense_backend/tests/unit/services/test_image_gen_api_base_defense.py
@@ -46,8 +46,8 @@ async def test_global_openrouter_image_gen_sets_explicit_api_base():
image_gen.response_format = None
image_gen.model = None
- search_space = MagicMock()
- search_space.image_gen_model_id = global_model["id"]
+ workspace = MagicMock()
+ workspace.image_gen_model_id = global_model["id"]
session = MagicMock()
with (
@@ -68,7 +68,7 @@ async def test_global_openrouter_image_gen_sets_explicit_api_base():
),
):
await image_generation_routes._execute_image_generation(
- session=session, image_gen=image_gen, search_space=search_space
+ session=session, image_gen=image_gen, workspace=workspace
)
assert captured.get("api_base") == "https://openrouter.ai/api/v1"
@@ -109,16 +109,16 @@ async def test_generate_image_tool_global_sets_explicit_api_base():
response._hidden_params = {"model": "openrouter/openai/gpt-image-1"}
return response
- search_space = MagicMock()
- search_space.id = 1
- search_space.image_gen_model_id = global_model["id"]
+ workspace = MagicMock()
+ workspace.id = 1
+ workspace.image_gen_model_id = global_model["id"]
session_cm = AsyncMock()
session = AsyncMock()
session_cm.__aenter__.return_value = session
scalars = MagicMock()
- scalars.first.return_value = search_space
+ scalars.first.return_value = workspace
exec_result = MagicMock()
exec_result.scalars.return_value = scalars
session.execute.return_value = exec_result
@@ -146,7 +146,7 @@ async def test_generate_image_tool_global_sets_explicit_api_base():
),
):
tool = gi_module.create_generate_image_tool(
- search_space_id=1, db_session=MagicMock()
+ workspace_id=1, db_session=MagicMock()
)
# The live tool takes an injected ToolRuntime and returns a Command;
# drive the raw coroutine with a minimal runtime (the tool only reads
diff --git a/surfsense_backend/tests/unit/services/test_quota_checked_vision_llm.py b/surfsense_backend/tests/unit/services/test_quota_checked_vision_llm.py
index 0f5dd531f..f50564141 100644
--- a/surfsense_backend/tests/unit/services/test_quota_checked_vision_llm.py
+++ b/surfsense_backend/tests/unit/services/test_quota_checked_vision_llm.py
@@ -77,7 +77,7 @@ async def test_ainvoke_routes_through_billable_call(monkeypatch):
wrapper = QuotaCheckedVisionLLM(
inner,
user_id=user_id,
- search_space_id=99,
+ workspace_id=99,
billing_tier="premium",
base_model="openai/gpt-4o",
quota_reserve_tokens=4000,
@@ -89,7 +89,7 @@ async def test_ainvoke_routes_through_billable_call(monkeypatch):
assert len(captured_kwargs) == 1
bc_kwargs = captured_kwargs[0]
assert bc_kwargs["user_id"] == user_id
- assert bc_kwargs["search_space_id"] == 99
+ assert bc_kwargs["workspace_id"] == 99
assert bc_kwargs["billing_tier"] == "premium"
assert bc_kwargs["base_model"] == "openai/gpt-4o"
assert bc_kwargs["quota_reserve_tokens"] == 4000
@@ -120,7 +120,7 @@ async def test_ainvoke_propagates_quota_insufficient_error(monkeypatch):
wrapper = QuotaCheckedVisionLLM(
inner,
user_id=uuid4(),
- search_space_id=1,
+ workspace_id=1,
billing_tier="premium",
base_model="openai/gpt-4o",
quota_reserve_tokens=4000,
@@ -146,7 +146,7 @@ async def test_proxies_non_overridden_attributes_to_inner():
wrapper = QuotaCheckedVisionLLM(
inner,
user_id=uuid4(),
- search_space_id=1,
+ workspace_id=1,
billing_tier="premium",
base_model="openai/gpt-4o",
quota_reserve_tokens=4000,
diff --git a/surfsense_backend/tests/unit/services/test_revert_filesystem_tools.py b/surfsense_backend/tests/unit/services/test_revert_filesystem_tools.py
index 95314741a..d614f5e61 100644
--- a/surfsense_backend/tests/unit/services/test_revert_filesystem_tools.py
+++ b/surfsense_backend/tests/unit/services/test_revert_filesystem_tools.py
@@ -93,7 +93,7 @@ def _action(*, tool_name: str, action_id: int = 7):
id=action_id,
tool_name=tool_name,
thread_id=1,
- search_space_id=2,
+ workspace_id=2,
user_id="user-1",
reverse_descriptor=None,
)
@@ -111,7 +111,7 @@ def _doc_revision(
revision = MagicMock()
revision.id = 100
revision.document_id = document_id
- revision.search_space_id = 2
+ revision.workspace_id = 2
revision.content_before = content_before
revision.title_before = title_before
revision.folder_id_before = folder_id_before
@@ -130,7 +130,7 @@ def _folder_revision(
revision = MagicMock()
revision.id = 200
revision.folder_id = folder_id
- revision.search_space_id = 2
+ revision.workspace_id = 2
revision.name_before = name_before
revision.parent_id_before = parent_id_before
revision.position_before = position_before
diff --git a/surfsense_backend/tests/unit/services/test_web_crawl_credit_service.py b/surfsense_backend/tests/unit/services/test_web_crawl_credit_service.py
new file mode 100644
index 000000000..ef806cfeb
--- /dev/null
+++ b/surfsense_backend/tests/unit/services/test_web_crawl_credit_service.py
@@ -0,0 +1,243 @@
+"""Unit tests for WebCrawlCreditService and the chat-scrape fold helper (Phase 3c).
+
+Covers:
+ A) successes_to_micros — config-driven price (the single source of truth),
+ including retune behaviour.
+ B) billing_enabled gate.
+ C) check_credits — sufficient / insufficient / disabled no-op.
+ D) charge_credits — debit / disabled no-op / zero no-op.
+ E) Chat-scrape fold helper — folds one success into the turn accumulator only
+ when billing is enabled and a turn is active.
+
+The wallet logic runs against the real service with a mock DB session at the
+system boundary (mirrors tests/unit/connector_indexers/test_etl_credits.py).
+"""
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from app.config import config
+from app.services.etl_credit_service import InsufficientCreditsError
+from app.services.web_crawl_credit_service import WebCrawlCreditService
+
+pytestmark = pytest.mark.unit
+
+_USER_ID = "00000000-0000-0000-0000-000000000001"
+
+
+@pytest.fixture(autouse=True)
+def _enable_crawl_billing(monkeypatch):
+ """Force crawl billing ON; default is off (self-hosted) which no-ops everything."""
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000)
+
+
+@pytest.fixture(autouse=True)
+def _stub_auto_reload(monkeypatch):
+ """charge_credits fires a best-effort auto-reload check; stub it out."""
+ import app.services.auto_reload_service as _ar
+
+ monkeypatch.setattr(_ar, "maybe_trigger_auto_reload", AsyncMock())
+
+
+class _FakeUser:
+ def __init__(self, balance_micros: int = 0, reserved_micros: int = 0):
+ self.credit_micros_balance = balance_micros
+ self.credit_micros_reserved = reserved_micros
+
+
+def _make_session(balance_micros: int = 100_000, reserved_micros: int = 0):
+ """Mock DB session compatible with get_available_micros + charge_credits."""
+ fake_user = _FakeUser(balance_micros, reserved_micros)
+ session = AsyncMock()
+
+ def _make_result(*_args, **_kwargs):
+ result = MagicMock()
+ result.first.return_value = (
+ fake_user.credit_micros_balance,
+ fake_user.credit_micros_reserved,
+ )
+ result.unique.return_value.scalar_one_or_none.return_value = fake_user
+ return result
+
+ session.execute = AsyncMock(side_effect=_make_result)
+ return session, fake_user
+
+
+# ===================================================================
+# A) successes_to_micros — config-driven price
+# ===================================================================
+
+
+class TestSuccessesToMicros:
+ def test_default_is_one_dollar_per_thousand(self, monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000)
+ assert WebCrawlCreditService.successes_to_micros(1) == 1000
+ assert WebCrawlCreditService.successes_to_micros(1000) == 1_000_000 # $1
+
+ def test_zero_successes_cost_nothing(self):
+ assert WebCrawlCreditService.successes_to_micros(0) == 0
+
+ @pytest.mark.parametrize(
+ ("per_success", "successes", "expected"),
+ [
+ (2000, 1000, 2_000_000), # $2 / 1000
+ (500, 1000, 500_000), # $0.50 / 1000
+ (5000, 10, 50_000), # $5 / 1000
+ ],
+ )
+ def test_retune_is_config_driven(
+ self, monkeypatch, per_success, successes, expected
+ ):
+ """No hardcoded rate: changing the config knob changes the price."""
+ monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", per_success)
+ assert WebCrawlCreditService.successes_to_micros(successes) == expected
+
+
+# ===================================================================
+# B) billing_enabled gate
+# ===================================================================
+
+
+class TestBillingEnabled:
+ def test_reads_config_flag(self, monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
+ assert WebCrawlCreditService.billing_enabled() is True
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False)
+ assert WebCrawlCreditService.billing_enabled() is False
+
+
+# ===================================================================
+# C) check_credits
+# ===================================================================
+
+
+class TestCheckCredits:
+ async def test_sufficient_credit_passes(self):
+ session, _user = _make_session(balance_micros=10_000)
+ svc = WebCrawlCreditService(session)
+ # 5 URLs * 1000 = 5000 <= 10000 → no raise
+ await svc.check_credits(_USER_ID, estimated_successes=5)
+
+ async def test_insufficient_credit_raises(self):
+ session, _user = _make_session(balance_micros=3_000)
+ svc = WebCrawlCreditService(session)
+ # 5 URLs * 1000 = 5000 > 3000 → raise
+ with pytest.raises(InsufficientCreditsError):
+ await svc.check_credits(_USER_ID, estimated_successes=5)
+
+ async def test_reserved_credit_reduces_available(self):
+ session, _user = _make_session(balance_micros=10_000, reserved_micros=8_000)
+ svc = WebCrawlCreditService(session)
+ # available = 2000; 3 * 1000 = 3000 > 2000 → raise
+ with pytest.raises(InsufficientCreditsError):
+ await svc.check_credits(_USER_ID, estimated_successes=3)
+
+ async def test_disabled_is_noop(self, monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False)
+ session, _user = _make_session(balance_micros=0)
+ svc = WebCrawlCreditService(session)
+ await svc.check_credits(_USER_ID, estimated_successes=10_000)
+ session.execute.assert_not_called()
+
+
+# ===================================================================
+# D) charge_credits
+# ===================================================================
+
+
+class TestChargeCredits:
+ async def test_debits_per_success(self):
+ session, user = _make_session(balance_micros=100_000)
+ svc = WebCrawlCreditService(session)
+ new_balance = await svc.charge_credits(_USER_ID, successes=3)
+ assert user.credit_micros_balance == 100_000 - 3 * 1000
+ assert new_balance == 97_000
+ session.commit.assert_awaited()
+
+ async def test_zero_successes_is_noop(self):
+ session, user = _make_session(balance_micros=100_000)
+ svc = WebCrawlCreditService(session)
+ result = await svc.charge_credits(_USER_ID, successes=0)
+ assert result is None
+ assert user.credit_micros_balance == 100_000
+ session.commit.assert_not_awaited()
+
+ async def test_disabled_is_noop(self, monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False)
+ session, user = _make_session(balance_micros=100_000)
+ svc = WebCrawlCreditService(session)
+ result = await svc.charge_credits(_USER_ID, successes=5)
+ assert result is None
+ assert user.credit_micros_balance == 100_000
+ session.execute.assert_not_called()
+
+
+# ===================================================================
+# E) Captcha per-attempt unit (Phase 3d)
+# ===================================================================
+
+
+@pytest.fixture
+def _enable_captcha_billing(monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_BILLING_ENABLED", True)
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE", 3000)
+
+
+class TestCaptchaStatics:
+ def test_billing_enabled_reads_flag(self, monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_BILLING_ENABLED", True)
+ assert WebCrawlCreditService.captcha_billing_enabled() is True
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_BILLING_ENABLED", False)
+ assert WebCrawlCreditService.captcha_billing_enabled() is False
+
+ def test_solves_to_micros_is_config_driven(self, monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE", 3000)
+ assert WebCrawlCreditService.captcha_solves_to_micros(1) == 3000
+ assert WebCrawlCreditService.captcha_solves_to_micros(1000) == 3_000_000
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE", 5000)
+ assert WebCrawlCreditService.captcha_solves_to_micros(2) == 10_000
+
+
+class TestChargeCaptcha:
+ async def test_debits_per_attempt(self, _enable_captcha_billing):
+ session, user = _make_session(balance_micros=100_000)
+ svc = WebCrawlCreditService(session)
+ new_balance = await svc.charge_captcha(_USER_ID, attempts=2)
+ assert user.credit_micros_balance == 100_000 - 2 * 3000
+ assert new_balance == 94_000
+ session.commit.assert_awaited()
+
+ async def test_zero_attempts_is_noop(self, _enable_captcha_billing):
+ session, user = _make_session(balance_micros=100_000)
+ svc = WebCrawlCreditService(session)
+ assert await svc.charge_captcha(_USER_ID, attempts=0) is None
+ assert user.credit_micros_balance == 100_000
+ session.commit.assert_not_awaited()
+
+ async def test_disabled_is_noop(self, monkeypatch):
+ monkeypatch.setattr(config, "WEB_CRAWL_CAPTCHA_BILLING_ENABLED", False)
+ session, user = _make_session(balance_micros=100_000)
+ svc = WebCrawlCreditService(session)
+ assert await svc.charge_captcha(_USER_ID, attempts=5) is None
+ assert user.credit_micros_balance == 100_000
+ session.execute.assert_not_called()
+
+
+class TestCheckBalance:
+ """Combined (crawl + captcha) pre-flight primitive — ungated."""
+
+ async def test_passes_when_affordable(self):
+ session, _user = _make_session(balance_micros=10_000)
+ await WebCrawlCreditService(session).check_balance(_USER_ID, 5_000)
+
+ async def test_raises_when_short(self):
+ session, _user = _make_session(balance_micros=3_000)
+ with pytest.raises(InsufficientCreditsError):
+ await WebCrawlCreditService(session).check_balance(_USER_ID, 5_000)
+
+ async def test_zero_required_is_noop(self):
+ session, _user = _make_session(balance_micros=0)
+ await WebCrawlCreditService(session).check_balance(_USER_ID, 0)
+ session.execute.assert_not_called()
diff --git a/surfsense_backend/tests/unit/tasks/chat/streaming/test_capability_emissions.py b/surfsense_backend/tests/unit/tasks/chat/streaming/test_capability_emissions.py
new file mode 100644
index 000000000..46a373b8d
--- /dev/null
+++ b/surfsense_backend/tests/unit/tasks/chat/streaming/test_capability_emissions.py
@@ -0,0 +1,131 @@
+"""Emission handlers for the capability chat tools (web_scrape / web_discover)."""
+
+from __future__ import annotations
+
+import importlib
+
+
+class _FakeStreaming:
+ def __init__(self) -> None:
+ self.terminals: list[tuple[str, str]] = []
+
+ def format_terminal_info(self, text: str, message_type: str = "info") -> str:
+ self.terminals.append((text, message_type))
+ return f"TERM::{message_type}"
+
+
+class _FakeCtx:
+ """Minimal stand-in for ToolCompletionEmissionContext (only what handlers use)."""
+
+ def __init__(self, tool_output: object) -> None:
+ self.tool_output = tool_output
+ self.cards: list[dict] = []
+ self.streaming_service = _FakeStreaming()
+
+ def emit_tool_output_card(self, payload: dict) -> str:
+ self.cards.append(payload)
+ return "CARD"
+
+
+def _scrape_frames(ctx: _FakeCtx) -> list[str]:
+ mod = importlib.import_module(
+ "app.tasks.chat.streaming.handlers.tools.web_scrape.emission"
+ )
+ return list(mod.iter_completion_emission_frames(ctx))
+
+
+def _discover_frames(ctx: _FakeCtx) -> list[str]:
+ mod = importlib.import_module(
+ "app.tasks.chat.streaming.handlers.tools.web_discover.emission"
+ )
+ return list(mod.iter_completion_emission_frames(ctx))
+
+
+def test_scrape_card_previews_content_and_summarizes():
+ long_body = "x" * 2000
+ ctx = _FakeCtx(
+ {
+ "rows": [
+ {
+ "url": "https://a.com",
+ "status": "success",
+ "content": long_body,
+ "metadata": {"title": "A"},
+ },
+ {"url": "https://b.com", "status": "failed", "error": "boom"},
+ ]
+ }
+ )
+
+ _scrape_frames(ctx)
+
+ [card] = ctx.cards
+ assert card["succeeded"] == 1
+ assert card["total"] == 2
+ page = card["pages"][0]
+ # full content must not be dumped into the card; a bounded preview is used
+ assert "content" not in page
+ assert len(page["content_preview"]) < len(long_body)
+ assert page["metadata"] == {"title": "A"}
+ assert card["pages"][1]["error"] == "boom"
+
+
+def test_scrape_terminal_success_when_any_page_succeeds():
+ ctx = _FakeCtx(
+ {"rows": [{"url": "https://a.com", "status": "success", "content": "hi"}]}
+ )
+
+ _scrape_frames(ctx)
+
+ assert ctx.streaming_service.terminals[-1][1] == "success"
+
+
+def test_scrape_terminal_error_when_all_pages_fail():
+ ctx = _FakeCtx(
+ {"rows": [{"url": "https://a.com", "status": "failed", "error": "boom"}]}
+ )
+
+ _scrape_frames(ctx)
+
+ assert ctx.streaming_service.terminals[-1][1] == "error"
+
+
+def test_scrape_non_dict_output_is_an_error_card():
+ ctx = _FakeCtx("Insufficient credit to continue.")
+
+ _scrape_frames(ctx)
+
+ assert ctx.cards[0]["status"] == "error"
+ assert ctx.streaming_service.terminals[-1][1] == "error"
+
+
+def test_discover_card_lists_hits_and_counts():
+ ctx = _FakeCtx(
+ {
+ "hits": [
+ {"url": "https://a.com", "title": "A", "snippet": "s", "provider": "p"},
+ {
+ "url": "https://b.com",
+ "title": "B",
+ "snippet": None,
+ "provider": "p",
+ },
+ ]
+ }
+ )
+
+ _discover_frames(ctx)
+
+ [card] = ctx.cards
+ assert card["count"] == 2
+ assert card["hits"][0]["url"] == "https://a.com"
+ assert ctx.streaming_service.terminals[-1][1] == "success"
+
+
+def test_discover_non_dict_output_is_an_error_card():
+ ctx = _FakeCtx("Search is not available.")
+
+ _discover_frames(ctx)
+
+ assert ctx.cards[0]["status"] == "error"
+ assert ctx.streaming_service.terminals[-1][1] == "error"
diff --git a/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py b/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py
index 7bb169496..09a0351b7 100644
--- a/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py
+++ b/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py
@@ -32,12 +32,10 @@ def _patch_common_bundle_dependencies(monkeypatch: pytest.MonkeyPatch):
_CapturedChatLiteLLM.calls = []
- async def _fake_search_space(
- _session: Any, _search_space_id: int
- ) -> SimpleNamespace:
+ async def _fake_workspace(_session: Any, _workspace_id: int) -> SimpleNamespace:
return SimpleNamespace(id=42, user_id="user-1")
- monkeypatch.setattr(llm_bundle, "_load_search_space", _fake_search_space)
+ monkeypatch.setattr(llm_bundle, "_load_workspace", _fake_workspace)
monkeypatch.setattr(llm_bundle, "SanitizedChatLiteLLM", _CapturedChatLiteLLM)
monkeypatch.setattr(llm_bundle, "register_model_usage_metadata", lambda **_kw: None)
monkeypatch.setattr(
@@ -65,9 +63,9 @@ async def test_load_llm_bundle_enables_streaming_for_db_models(
connection=connection,
)
- async def _fake_db_model(_session: Any, *, model_id: int, search_space: Any) -> Any:
+ async def _fake_db_model(_session: Any, *, model_id: int, workspace: Any) -> Any:
assert model_id == 7
- assert search_space.id == 42
+ assert workspace.id == 42
return model
monkeypatch.setattr(llm_bundle, "_load_db_model", _fake_db_model)
@@ -83,7 +81,7 @@ async def test_load_llm_bundle_enables_streaming_for_db_models(
llm, agent_config, error = await llm_bundle.load_llm_bundle(
object(),
config_id=7,
- search_space_id=42,
+ workspace_id=42,
)
assert error is None
@@ -140,7 +138,7 @@ async def test_load_llm_bundle_enables_streaming_for_global_models(
llm, agent_config, error = await llm_bundle.load_llm_bundle(
object(),
config_id=-11,
- search_space_id=42,
+ workspace_id=42,
)
assert error is None
diff --git a/surfsense_backend/tests/unit/tasks/chat/streaming/test_task_span.py b/surfsense_backend/tests/unit/tasks/chat/streaming/test_task_span.py
index df680018d..756d397cd 100644
--- a/surfsense_backend/tests/unit/tasks/chat/streaming/test_task_span.py
+++ b/surfsense_backend/tests/unit/tasks/chat/streaming/test_task_span.py
@@ -28,7 +28,7 @@ def test_clear_ignored_for_non_task_tool() -> None:
open_task_span(state, run_id="run-1")
sid = state.active_span_id
clear_task_span_if_delegating_task_ended(
- state, tool_name="web_search", run_id="run-1"
+ state, tool_name="scrape_webpage", run_id="run-1"
)
assert state.active_span_id == sid
assert state.active_task_run_id == "run-1"
diff --git a/surfsense_backend/tests/unit/tasks/chat/test_content_builder.py b/surfsense_backend/tests/unit/tasks/chat/test_content_builder.py
index 42e62d26b..662fb8adf 100644
--- a/surfsense_backend/tests/unit/tasks/chat/test_content_builder.py
+++ b/surfsense_backend/tests/unit/tasks/chat/test_content_builder.py
@@ -128,14 +128,14 @@ class TestToolHeavyTurn:
b.on_tool_input_start(
ui_id="call_run123",
- tool_name="web_search",
+ tool_name="scrape_webpage",
langchain_tool_call_id="lc_tool_abc",
)
b.on_tool_input_delta("call_run123", '{"query":')
b.on_tool_input_delta("call_run123", '"surfsense"}')
b.on_tool_input_available(
ui_id="call_run123",
- tool_name="web_search",
+ tool_name="scrape_webpage",
args={"query": "surfsense"},
langchain_tool_call_id="lc_tool_abc",
)
@@ -150,7 +150,7 @@ class TestToolHeavyTurn:
tool_part = snap[1]
assert tool_part["type"] == "tool-call"
assert tool_part["toolCallId"] == "call_run123"
- assert tool_part["toolName"] == "web_search"
+ assert tool_part["toolName"] == "scrape_webpage"
assert tool_part["args"] == {"query": "surfsense"}
# ``argsText`` is the pretty-printed final JSON, not the raw
# streaming buffer (FE ``stream-pipeline.ts:128``).
diff --git a/surfsense_backend/tests/unit/tasks/test_celery_async_runner.py b/surfsense_backend/tests/unit/tasks/test_celery_async_runner.py
index 2342dd8da..761c12cd8 100644
--- a/surfsense_backend/tests/unit/tasks/test_celery_async_runner.py
+++ b/surfsense_backend/tests/unit/tasks/test_celery_async_runner.py
@@ -293,6 +293,81 @@ def test_runner_runs_shutdown_asyncgens_before_close() -> None:
gc.collect()
+@contextmanager
+def _patch_checkpointer_close(recorder: list[int]) -> Iterator[None]:
+ """Patch ``close_checkpointer`` to record the loop it runs on.
+
+ The runner imports it lazily, so we patch the attribute on the
+ already-loaded checkpointer module (same trick as the engine stub).
+ """
+ import app.agents.chat.runtime.checkpointer as cp
+
+ original = cp.close_checkpointer
+
+ async def _stub() -> None:
+ recorder.append(id(asyncio.get_running_loop()))
+
+ cp.close_checkpointer = _stub # type: ignore[assignment]
+ try:
+ yield
+ finally:
+ cp.close_checkpointer = original # type: ignore[assignment]
+
+
+def test_runner_disposes_checkpointer_pool_around_call() -> None:
+ """The durable checkpointer's loop-bound pool must be dropped before
+ and after each task, on the task's own fresh loop — the fix that lets
+ a worker use the shared AsyncPostgresSaver instead of InMemorySaver.
+ """
+ from app.tasks.celery_tasks import run_async_celery_task
+
+ engine_stub = _StaleLoopEngine()
+ cp_loops: list[int] = []
+
+ async def _body() -> str:
+ # Both the engine and the checkpointer are disposed once before we run.
+ assert len(cp_loops) == 1
+ return "ok"
+
+ with _patch_shared_engine(engine_stub), _patch_checkpointer_close(cp_loops):
+ assert run_async_celery_task(_body) == "ok"
+
+ # Once before the body, once after (finally).
+ assert len(cp_loops) == 2
+ # Both ran on the SAME fresh loop, matching the engine's dispose loop.
+ assert cp_loops[0] == cp_loops[1]
+ assert cp_loops[0] == engine_stub.dispose_loop_ids[0]
+
+
+def test_runner_swallows_checkpointer_dispose_errors() -> None:
+ """A flaky checkpointer dispose must never take down a celery task."""
+ from app.tasks.celery_tasks import run_async_celery_task
+
+ engine_stub = _StaleLoopEngine()
+
+ import app.agents.chat.runtime.checkpointer as cp
+
+ original = cp.close_checkpointer
+ calls = {"n": 0}
+
+ async def _angry() -> None:
+ calls["n"] += 1
+ raise RuntimeError("checkpointer dispose blew up")
+
+ cp.close_checkpointer = _angry # type: ignore[assignment]
+
+ async def _body() -> int:
+ return 42
+
+ try:
+ with _patch_shared_engine(engine_stub):
+ assert run_async_celery_task(_body) == 42
+ finally:
+ cp.close_checkpointer = original # type: ignore[assignment]
+
+ assert calls["n"] == 2 # before + after both attempted, both swallowed
+
+
def test_runner_uses_proactor_loop_on_windows() -> None:
"""On Windows the celery worker preselects a Proactor policy so
subprocess (ffmpeg) calls work. The helper must not silently fall
diff --git a/surfsense_backend/tests/unit/tasks/test_video_presentation_billing.py b/surfsense_backend/tests/unit/tasks/test_video_presentation_billing.py
index 7183024ed..ad84d0e0f 100644
--- a/surfsense_backend/tests/unit/tasks/test_video_presentation_billing.py
+++ b/surfsense_backend/tests/unit/tasks/test_video_presentation_billing.py
@@ -145,14 +145,14 @@ async def test_billable_call_invoked_with_correct_kwargs_for_free_config(monkeyp
user_id = uuid4()
- async def _fake_resolver(sess, search_space_id, *, thread_id=None):
- assert search_space_id == 777
+ async def _fake_resolver(sess, workspace_id, *, thread_id=None):
+ assert workspace_id == 777
assert thread_id == 99
return user_id, "free", "openrouter/some-free-model"
monkeypatch.setattr(
video_presentation_tasks,
- "_resolve_agent_billing_for_search_space",
+ "_resolve_agent_billing_for_workspace",
_fake_resolver,
)
monkeypatch.setattr(video_presentation_tasks, "billable_call", _ok_billable_call)
@@ -169,7 +169,7 @@ async def test_billable_call_invoked_with_correct_kwargs_for_free_config(monkeyp
result = await video_presentation_tasks._generate_video_presentation(
video_presentation_id=11,
source_content="content",
- search_space_id=777,
+ workspace_id=777,
user_prompt=None,
)
@@ -180,7 +180,7 @@ async def test_billable_call_invoked_with_correct_kwargs_for_free_config(monkeyp
assert len(_CALL_LOG) == 1
call = _CALL_LOG[0]
assert call["user_id"] == user_id
- assert call["search_space_id"] == 777
+ assert call["workspace_id"] == 777
assert call["billing_tier"] == "free"
assert call["base_model"] == "openrouter/some-free-model"
assert call["usage_type"] == "video_presentation_generation"
@@ -213,12 +213,12 @@ async def test_billable_call_invoked_with_premium_tier(monkeypatch):
user_id = uuid4()
- async def _fake_resolver(sess, search_space_id, *, thread_id=None):
+ async def _fake_resolver(sess, workspace_id, *, thread_id=None):
return user_id, "premium", "gpt-5.4"
monkeypatch.setattr(
video_presentation_tasks,
- "_resolve_agent_billing_for_search_space",
+ "_resolve_agent_billing_for_workspace",
_fake_resolver,
)
monkeypatch.setattr(video_presentation_tasks, "billable_call", _ok_billable_call)
@@ -235,7 +235,7 @@ async def test_billable_call_invoked_with_premium_tier(monkeypatch):
await video_presentation_tasks._generate_video_presentation(
video_presentation_id=11,
source_content="content",
- search_space_id=777,
+ workspace_id=777,
user_prompt=None,
)
@@ -256,12 +256,12 @@ async def test_quota_insufficient_marks_video_failed_and_skips_graph(monkeypatch
lambda: _FakeSessionMaker(session),
)
- async def _fake_resolver(sess, search_space_id, *, thread_id=None):
+ async def _fake_resolver(sess, workspace_id, *, thread_id=None):
return uuid4(), "premium", "gpt-5.4"
monkeypatch.setattr(
video_presentation_tasks,
- "_resolve_agent_billing_for_search_space",
+ "_resolve_agent_billing_for_workspace",
_fake_resolver,
)
monkeypatch.setattr(
@@ -283,7 +283,7 @@ async def test_quota_insufficient_marks_video_failed_and_skips_graph(monkeypatch
result = await video_presentation_tasks._generate_video_presentation(
video_presentation_id=12,
source_content="content",
- search_space_id=777,
+ workspace_id=777,
user_prompt=None,
)
@@ -309,12 +309,12 @@ async def test_billing_settlement_failure_marks_video_failed(monkeypatch):
lambda: _FakeSessionMaker(session),
)
- async def _fake_resolver(sess, search_space_id, *, thread_id=None):
+ async def _fake_resolver(sess, workspace_id, *, thread_id=None):
return uuid4(), "premium", "gpt-5.4"
monkeypatch.setattr(
video_presentation_tasks,
- "_resolve_agent_billing_for_search_space",
+ "_resolve_agent_billing_for_workspace",
_fake_resolver,
)
monkeypatch.setattr(
@@ -335,7 +335,7 @@ async def test_billing_settlement_failure_marks_video_failed(monkeypatch):
result = await video_presentation_tasks._generate_video_presentation(
video_presentation_id=14,
source_content="content",
- search_space_id=777,
+ workspace_id=777,
user_prompt=None,
)
@@ -360,12 +360,12 @@ async def test_resolver_failure_marks_video_failed(monkeypatch):
lambda: _FakeSessionMaker(session),
)
- async def _failing_resolver(sess, search_space_id, *, thread_id=None):
- raise ValueError("Search space 777 not found")
+ async def _failing_resolver(sess, workspace_id, *, thread_id=None):
+ raise ValueError("Workspace 777 not found")
monkeypatch.setattr(
video_presentation_tasks,
- "_resolve_agent_billing_for_search_space",
+ "_resolve_agent_billing_for_workspace",
_failing_resolver,
)
@@ -384,7 +384,7 @@ async def test_resolver_failure_marks_video_failed(monkeypatch):
result = await video_presentation_tasks._generate_video_presentation(
video_presentation_id=13,
source_content="content",
- search_space_id=777,
+ workspace_id=777,
user_prompt=None,
)
diff --git a/surfsense_backend/tests/unit/test_pat_fail_closed_static.py b/surfsense_backend/tests/unit/test_pat_fail_closed_static.py
index 88b8f9151..17bdc4ca0 100644
--- a/surfsense_backend/tests/unit/test_pat_fail_closed_static.py
+++ b/surfsense_backend/tests/unit/test_pat_fail_closed_static.py
@@ -16,9 +16,7 @@ ALLOW_ANY_EXPECTED = {
"routes/obsidian_plugin_routes.py": (
"_auth: AuthContext = Depends(allow_any_principal)"
),
- "routes/search_spaces_routes.py": (
- "auth: AuthContext = Depends(allow_any_principal)"
- ),
+ "routes/workspaces_routes.py": ("auth: AuthContext = Depends(allow_any_principal)"),
}
CONNECTOR_LISTERS = [
@@ -60,12 +58,12 @@ def test_allow_any_principal_is_only_used_by_bootstrap_allowlist() -> None:
assert expected_snippet in text
-def test_connector_listers_route_pat_through_search_space_gate() -> None:
+def test_connector_listers_route_pat_through_workspace_gate() -> None:
for rel_path in CONNECTOR_LISTERS:
text = (APP_ROOT / rel_path).read_text()
assert "auth: AuthContext = Depends(get_auth_context)" in text, rel_path
assert (
- "await check_search_space_access(session, auth, connector.search_space_id)"
+ "await check_workspace_access(session, auth, connector.workspace_id)"
in text
), rel_path
diff --git a/surfsense_backend/tests/unit/utils/crawl/test_contacts.py b/surfsense_backend/tests/unit/utils/crawl/test_contacts.py
new file mode 100644
index 000000000..c143ed820
--- /dev/null
+++ b/surfsense_backend/tests/unit/utils/crawl/test_contacts.py
@@ -0,0 +1,103 @@
+"""``extract_contacts`` behavior: harvest emails/phones/socials from raw HTML."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.utils.crawl import extract_contacts
+
+pytestmark = pytest.mark.unit
+
+
+def test_harvests_mailto_tel_and_socials() -> None:
+ html = """
+
+
+
+ """
+ c = extract_contacts(html)
+ assert c.emails == ["hello@acme.io"] # mailto query stripped
+ assert c.phones == ["+1-555-0100"]
+ assert c.socials == [
+ "https://www.linkedin.com/company/acme",
+ "https://x.com/acme",
+ "https://github.com/acme/repo", # fragment stripped
+ ]
+ # Same-site, non-social link is not a contact signal.
+ assert "https://acme.io/about" not in c.socials
+
+
+def test_plaintext_email_without_mailto_is_found() -> None:
+ html = "Reach us at hello@cochat.ai for support.
"
+ assert extract_contacts(html).emails == ["hello@cochat.ai"]
+
+
+def test_filters_noise_emails_and_asset_false_positives() -> None:
+ html = """
+
+
+
+ ops@sentry.io
+ x
+
+ """
+ assert extract_contacts(html).emails == ["real@company.com"]
+
+
+def test_filters_template_placeholders() -> None:
+ html = """
+
+ youremail@business.com your.email@acme.io john-doe@acme.io
+ real
+ gh template
+ tw template
+ real person
+
+ """
+ c = extract_contacts(html)
+ assert c.emails == ["hello@acme.io"] # your.email + john-doe normalized away
+ assert c.socials == ["https://www.linkedin.com/in/jane-doe/"]
+
+
+def test_regional_social_hosts_are_harvested() -> None:
+ """WhatsApp/Line/VK/Weibo etc. are the business contact channel outside the US."""
+ html = """
+ WhatsApp
+ Line
+ VK
+ Weibo
+ Xing
+ """
+ assert len(extract_contacts(html).socials) == 5
+
+
+def test_percent_encoded_hrefs_are_decoded() -> None:
+ """Sites URL-encode tel/mailto hrefs (seen live: tel:+1%20408-629-1770)."""
+ html = """
+ Call
+ Email
+ """
+ c = extract_contacts(html)
+ assert c.phones == ["+1 408-629-1770"]
+ assert "hello@acme.io" in c.emails
+
+
+def test_dedupes_case_insensitively_preserving_order() -> None:
+ html = """
+ a
+ b
+ """
+ assert extract_contacts(html).emails == ["Hello@Acme.io"]
+
+
+def test_empty_or_unparseable_html_is_empty() -> None:
+ assert extract_contacts("").is_empty
+ assert extract_contacts(None).is_empty
+ assert extract_contacts(" ").is_empty
diff --git a/surfsense_backend/tests/unit/utils/proxy/__init__.py b/surfsense_backend/tests/unit/utils/proxy/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/utils/proxy/test_custom_provider.py b/surfsense_backend/tests/unit/utils/proxy/test_custom_provider.py
new file mode 100644
index 000000000..c5563290d
--- /dev/null
+++ b/surfsense_backend/tests/unit/utils/proxy/test_custom_provider.py
@@ -0,0 +1,79 @@
+"""Unit tests for the BYO ``CustomProxyProvider`` (Phase 3b).
+
+Covers single-endpoint vs pool behavior, cyclic rotation, env de-duplication,
+the empty/unconfigured case, and the playwright-dict parse.
+"""
+
+import pytest
+
+from app.config import Config
+from app.utils.proxy.providers.custom import CustomProxyProvider
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture(autouse=True)
+def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Default both knobs to unset; individual tests set what they need."""
+ monkeypatch.setattr(Config, "PROXY_URL", None)
+ monkeypatch.setattr(Config, "PROXY_URLS", None)
+
+
+def test_single_url_is_static(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(Config, "PROXY_URL", "http://u:p@host:8080")
+ provider = CustomProxyProvider()
+
+ assert provider.is_pool_backed is False
+ assert provider.get_proxy_url() == "http://u:p@host:8080"
+ # Static endpoint returns the same value every call.
+ assert provider.get_proxy_url() == "http://u:p@host:8080"
+
+
+def test_pool_rotates_cyclically(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(
+ Config,
+ "PROXY_URLS",
+ "http://a:1@h1:8080, http://b:2@h2:8080 ,http://c:3@h3:8080",
+ )
+ provider = CustomProxyProvider()
+
+ assert provider.is_pool_backed is True
+ seen = [provider.get_proxy_url() for _ in range(4)]
+ assert seen == [
+ "http://a:1@h1:8080",
+ "http://b:2@h2:8080",
+ "http://c:3@h3:8080",
+ "http://a:1@h1:8080", # wraps around
+ ]
+
+
+def test_single_plus_pool_dedupes(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A URL present in both PROXY_URL and the pool is not duplicated."""
+ monkeypatch.setattr(Config, "PROXY_URLS", "http://a@h1:80,http://b@h2:80")
+ monkeypatch.setattr(Config, "PROXY_URL", "http://a@h1:80")
+ provider = CustomProxyProvider()
+
+ assert provider.is_pool_backed is True
+ seen = [provider.get_proxy_url() for _ in range(3)]
+ assert seen == ["http://a@h1:80", "http://b@h2:80", "http://a@h1:80"]
+
+
+def test_unconfigured_returns_none() -> None:
+ provider = CustomProxyProvider()
+
+ assert provider.is_pool_backed is False
+ assert provider.get_proxy_url() is None
+ assert provider.get_requests_proxies() is None
+
+
+def test_playwright_proxy_parses_credentials(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(Config, "PROXY_URL", "http://user:pass@host:8080")
+ provider = CustomProxyProvider()
+
+ assert provider.get_playwright_proxy() == {
+ "server": "http://host:8080",
+ "username": "user",
+ "password": "pass",
+ }
diff --git a/surfsense_backend/tests/unit/utils/proxy/test_dataimpulse_provider.py b/surfsense_backend/tests/unit/utils/proxy/test_dataimpulse_provider.py
new file mode 100644
index 000000000..af1288046
--- /dev/null
+++ b/surfsense_backend/tests/unit/utils/proxy/test_dataimpulse_provider.py
@@ -0,0 +1,70 @@
+"""Unit tests for the DataImpulseProvider.
+
+Takes a single full URL (like ``custom``); the vendor-specific bit is parsing the
+``__cr.`` username suffix for :meth:`get_location`. Playwright/requests
+shapes come from the shared base parse, so a couple of checks cover the wiring.
+"""
+
+import pytest
+
+from app.config import Config
+from app.utils.proxy.providers.dataimpulse import DataImpulseProvider
+
+pytestmark = pytest.mark.unit
+
+_URL = "http://tok123__cr.us:secret@gw.dataimpulse.com:823"
+
+
+@pytest.fixture(autouse=True)
+def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(Config, "PROXY_URL", None)
+
+
+def test_returns_configured_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(Config, "PROXY_URL", _URL)
+ provider = DataImpulseProvider()
+
+ assert provider.is_pool_backed is False
+ assert provider.get_proxy_url() == _URL
+ assert provider.get_requests_proxies() == {"http": _URL, "https": _URL}
+
+
+def test_location_parsed_from_country_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(Config, "PROXY_URL", _URL)
+ assert DataImpulseProvider().get_location() == "us"
+
+
+def test_location_stops_at_next_param(monkeypatch: pytest.MonkeyPatch) -> None:
+ # A sticky/session suffix after the country must not bleed into the location.
+ monkeypatch.setattr(
+ Config,
+ "PROXY_URL",
+ "http://tok__cr.de__sid.abc123:secret@gw.dataimpulse.com:823",
+ )
+ assert DataImpulseProvider().get_location() == "de"
+
+
+def test_no_country_suffix_yields_empty_location(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(Config, "PROXY_URL", "http://tok:secret@gw.dataimpulse.com:823")
+ assert DataImpulseProvider().get_location() == ""
+
+
+def test_unconfigured_returns_none() -> None:
+ provider = DataImpulseProvider()
+
+ assert provider.get_proxy_url() is None
+ assert provider.get_requests_proxies() is None
+ assert provider.get_playwright_proxy() is None
+ assert provider.get_location() == ""
+
+
+def test_playwright_proxy_from_base_parse(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(Config, "PROXY_URL", _URL)
+
+ assert DataImpulseProvider().get_playwright_proxy() == {
+ "server": "http://gw.dataimpulse.com:823",
+ "username": "tok123__cr.us",
+ "password": "secret",
+ }
diff --git a/surfsense_backend/tests/unit/utils/proxy/test_registry.py b/surfsense_backend/tests/unit/utils/proxy/test_registry.py
new file mode 100644
index 000000000..5a6bde403
--- /dev/null
+++ b/surfsense_backend/tests/unit/utils/proxy/test_registry.py
@@ -0,0 +1,36 @@
+"""Unit tests for proxy provider selection.
+
+``PROXY_PROVIDER`` selects the single app-wide provider; ``custom`` (the default)
+and ``dataimpulse`` are registered, and unknown values still warn and fall back
+to the default.
+"""
+
+import pytest
+
+from app.config import Config
+from app.utils.proxy import registry
+from app.utils.proxy.providers.custom import CustomProxyProvider
+from app.utils.proxy.providers.dataimpulse import DataImpulseProvider
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture(autouse=True)
+def _reset_active_provider(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Clear the process-wide provider cache so each test resolves fresh."""
+ monkeypatch.setattr(registry, "_active_provider", None)
+
+
+def test_resolves_custom(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(Config, "PROXY_PROVIDER", "custom")
+ assert isinstance(registry.get_active_provider(), CustomProxyProvider)
+
+
+def test_resolves_dataimpulse(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(Config, "PROXY_PROVIDER", "dataimpulse")
+ assert isinstance(registry.get_active_provider(), DataImpulseProvider)
+
+
+def test_unknown_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(Config, "PROXY_PROVIDER", "does_not_exist")
+ assert isinstance(registry.get_active_provider(), CustomProxyProvider)
diff --git a/surfsense_backend/tests/unit/utils/test_captcha_config.py b/surfsense_backend/tests/unit/utils/test_captcha_config.py
new file mode 100644
index 000000000..95f92c06f
--- /dev/null
+++ b/surfsense_backend/tests/unit/utils/test_captcha_config.py
@@ -0,0 +1,47 @@
+"""Unit tests for the generic captcha config gate (Phase 3d, Apache-2 layer)."""
+
+import pytest
+
+from app.config import config
+from app.utils.captcha import captcha_enabled, get_captcha_config
+
+pytestmark = pytest.mark.unit
+
+
+class TestCaptchaEnabled:
+ def test_off_by_default(self, monkeypatch):
+ monkeypatch.setattr(config, "CAPTCHA_SOLVING_ENABLED", False)
+ monkeypatch.setattr(config, "CAPTCHA_SOLVER_API_KEY", "key")
+ assert captcha_enabled() is False
+
+ def test_flag_on_but_no_key_is_disabled(self, monkeypatch):
+ monkeypatch.setattr(config, "CAPTCHA_SOLVING_ENABLED", True)
+ monkeypatch.setattr(config, "CAPTCHA_SOLVER_API_KEY", None)
+ assert captcha_enabled() is False
+
+ def test_flag_on_with_key_is_enabled(self, monkeypatch):
+ monkeypatch.setattr(config, "CAPTCHA_SOLVING_ENABLED", True)
+ monkeypatch.setattr(config, "CAPTCHA_SOLVER_API_KEY", "key")
+ assert captcha_enabled() is True
+
+
+class TestGetCaptchaConfig:
+ def test_snapshot_reflects_config(self, monkeypatch):
+ monkeypatch.setattr(config, "CAPTCHA_SOLVING_ENABLED", True)
+ monkeypatch.setattr(config, "CAPTCHA_SOLVER_API_KEY", "key")
+ monkeypatch.setattr(config, "CAPTCHA_SOLVER_PROVIDER", "2captcha")
+ monkeypatch.setattr(config, "CAPTCHA_MAX_ATTEMPTS_PER_URL", 3)
+ monkeypatch.setattr(config, "CAPTCHA_SOLVE_TIMEOUT_S", 90)
+ monkeypatch.setattr(config, "CAPTCHA_TYPE_DEFAULT", "hcaptcha")
+
+ cfg = get_captcha_config()
+ assert cfg.enabled is True
+ assert cfg.solving_site == "2captcha"
+ assert cfg.max_attempts_per_url == 3
+ assert cfg.timeout_s == 90
+ assert cfg.captcha_type_default == "hcaptcha"
+
+ def test_keyless_config_is_not_enabled(self, monkeypatch):
+ monkeypatch.setattr(config, "CAPTCHA_SOLVING_ENABLED", True)
+ monkeypatch.setattr(config, "CAPTCHA_SOLVER_API_KEY", None)
+ assert get_captcha_config().enabled is False
diff --git a/surfsense_backend/tests/unit/utils/test_crawl_classifier.py b/surfsense_backend/tests/unit/utils/test_crawl_classifier.py
new file mode 100644
index 000000000..c595686c9
--- /dev/null
+++ b/surfsense_backend/tests/unit/utils/test_crawl_classifier.py
@@ -0,0 +1,75 @@
+"""Unit tests for the Phase 3e block classifier (Apache-2 layer)."""
+
+import pytest
+
+from app.utils.crawl import BlockType, classify_block
+
+pytestmark = pytest.mark.unit
+
+
+class TestClassifyBlock:
+ def test_ok_on_plain_content(self):
+ assert classify_block(200, "hello") is BlockType.OK
+
+ def test_none_body_is_empty(self):
+ assert classify_block(200, None) is BlockType.EMPTY
+ assert classify_block(200, " ") is BlockType.EMPTY
+
+ def test_rate_limited_takes_precedence(self):
+ # 429 wins even if a challenge marker is present in the body.
+ assert (
+ classify_block(429, '
')
+ is BlockType.RATE_LIMITED
+ )
+
+ @pytest.mark.parametrize(
+ "html",
+ [
+ "Just a moment... ",
+ "Checking your browser before accessing ",
+ "please enable javascript and cookies to continue",
+ "verify you are human",
+ '
',
+ '
',
+ '
',
+ "blocked by ddos-guard.net",
+ ],
+ )
+ def test_cloudflare_markers(self, html):
+ assert classify_block(403, html) is BlockType.CLOUDFLARE
+
+ def test_hcaptcha(self):
+ assert (
+ classify_block(200, '
')
+ is BlockType.CAPTCHA_HCAPTCHA
+ )
+
+ def test_recaptcha(self):
+ assert (
+ classify_block(200, '
')
+ is BlockType.CAPTCHA_RECAPTCHA
+ )
+
+ def test_datadome(self):
+ assert (
+ classify_block(403, "var dd={'host':'geo.captcha-delivery.com'}")
+ is BlockType.DATADOME
+ )
+
+ def test_kasada(self):
+ assert classify_block(200, "window.KPSDK.configure()") is BlockType.KASADA
+
+ def test_bot_gate_status_without_marker_is_unknown(self):
+ # 202/403 with no recognized challenge marker => generic blocked-ish.
+ assert classify_block(202, "x") is BlockType.UNKNOWN
+ assert classify_block(403, "x") is BlockType.UNKNOWN
+
+ def test_bot_gate_status_empty_body_is_unknown(self):
+ assert classify_block(403, None) is BlockType.UNKNOWN
+
+ def test_cloudflare_marker_beats_generic_403(self):
+ # A 403 Cloudflare page is CLOUDFLARE, not UNKNOWN.
+ assert (
+ classify_block(403, "Just a moment... ")
+ is BlockType.CLOUDFLARE
+ )
diff --git a/surfsense_backend/tests/unit/utils/test_validators.py b/surfsense_backend/tests/unit/utils/test_validators.py
index e0e7c6da8..2fedcf39e 100644
--- a/surfsense_backend/tests/unit/utils/test_validators.py
+++ b/surfsense_backend/tests/unit/utils/test_validators.py
@@ -4,6 +4,7 @@ import pytest
from fastapi import HTTPException
from app.utils.validators import (
+ raise_if_connector_deprecated,
validate_connector_config,
validate_connectors,
validate_document_ids,
@@ -11,10 +12,10 @@ from app.utils.validators import (
validate_messages,
validate_research_mode,
validate_search_mode,
- validate_search_space_id,
validate_top_k,
validate_url,
validate_uuid,
+ validate_workspace_id,
)
pytestmark = pytest.mark.unit
@@ -34,8 +35,8 @@ pytestmark = pytest.mark.unit
(" 42 ", 42),
],
)
-def test_validate_search_space_id_valid(valid_input, expected):
- assert validate_search_space_id(valid_input) == expected
+def test_validate_workspace_id_valid(valid_input, expected):
+ assert validate_workspace_id(valid_input) == expected
@pytest.mark.parametrize(
@@ -54,9 +55,9 @@ def test_validate_search_space_id_valid(valid_input, expected):
"-5",
],
)
-def test_validate_search_space_id_invalid(invalid_input):
+def test_validate_workspace_id_invalid(invalid_input):
with pytest.raises(HTTPException) as excinfo:
- validate_search_space_id(invalid_input)
+ validate_workspace_id(invalid_input)
assert excinfo.value.status_code == 400
@@ -332,9 +333,33 @@ def test_validate_connector_config_invalid():
with pytest.raises(ValueError):
validate_connector_config("SEARXNG_API", {"SEARXNG_HOST": "not-a-url"})
- # Invalid email format (if JIRA was enabled, etc. We test with WEBCRAWLER's custom validation)
- # Firecrawl key format error:
- with pytest.raises(ValueError):
- validate_connector_config(
- "WEBCRAWLER_CONNECTOR", {"FIRECRAWL_API_KEY": "invalid-prefix-key"}
- )
+
+@pytest.mark.parametrize(
+ "connector_type",
+ [
+ "DISCORD_CONNECTOR",
+ "TEAMS_CONNECTOR",
+ "LUMA_CONNECTOR",
+ "TAVILY_API",
+ "SEARXNG_API",
+ "LINKUP_API",
+ "BAIDU_SEARCH_API",
+ "YOUTUBE_CONNECTOR",
+ "WEBCRAWLER_CONNECTOR",
+ "ELASTICSEARCH_CONNECTOR",
+ ],
+)
+def test_raise_if_connector_deprecated_blocks(connector_type):
+ """Deprecated connector types are refused with HTTP 410."""
+ with pytest.raises(HTTPException) as excinfo:
+ raise_if_connector_deprecated(connector_type)
+ assert excinfo.value.status_code == 410
+
+
+@pytest.mark.parametrize(
+ "connector_type",
+ ["SLACK_CONNECTOR", "NOTION_CONNECTOR", "SERPER_API", "MCP_CONNECTOR"],
+)
+def test_raise_if_connector_deprecated_allows_active(connector_type):
+ """Active connector types pass through without raising."""
+ raise_if_connector_deprecated(connector_type)
diff --git a/surfsense_backend/tests/utils/helpers.py b/surfsense_backend/tests/utils/helpers.py
index fc77c6e6b..601761ed0 100644
--- a/surfsense_backend/tests/utils/helpers.py
+++ b/surfsense_backend/tests/utils/helpers.py
@@ -40,17 +40,17 @@ async def get_auth_token(client: httpx.AsyncClient) -> str:
return response.json()["access_token"]
-async def get_search_space_id(client: httpx.AsyncClient, token: str) -> int:
- """Fetch the first search space owned by the test user."""
+async def get_workspace_id(client: httpx.AsyncClient, token: str) -> int:
+ """Fetch the first workspace owned by the test user."""
resp = await client.get(
- "/api/v1/searchspaces",
+ "/api/v1/workspaces",
headers=auth_headers(token),
)
assert resp.status_code == 200, (
- f"Failed to list search spaces ({resp.status_code}): {resp.text}"
+ f"Failed to list workspaces ({resp.status_code}): {resp.text}"
)
spaces = resp.json()
- assert len(spaces) > 0, "No search spaces found for test user"
+ assert len(spaces) > 0, "No workspaces found for test user"
return spaces[0]["id"]
@@ -64,7 +64,7 @@ async def upload_file(
headers: dict[str, str],
fixture_name: str,
*,
- search_space_id: int,
+ workspace_id: int,
filename_override: str | None = None,
) -> httpx.Response:
"""Upload a single fixture file and return the raw response."""
@@ -75,7 +75,7 @@ async def upload_file(
"/api/v1/documents/fileupload",
headers=headers,
files={"files": (upload_name, f)},
- data={"search_space_id": str(search_space_id)},
+ data={"workspace_id": str(workspace_id)},
)
@@ -84,7 +84,7 @@ async def upload_multiple_files(
headers: dict[str, str],
fixture_names: list[str],
*,
- search_space_id: int,
+ workspace_id: int,
) -> httpx.Response:
"""Upload multiple fixture files in a single request."""
files = []
@@ -99,7 +99,7 @@ async def upload_multiple_files(
"/api/v1/documents/fileupload",
headers=headers,
files=files,
- data={"search_space_id": str(search_space_id)},
+ data={"workspace_id": str(workspace_id)},
)
finally:
for fh in open_handles:
@@ -111,7 +111,7 @@ async def poll_document_status(
headers: dict[str, str],
document_ids: list[int],
*,
- search_space_id: int,
+ workspace_id: int,
timeout: float = 180.0,
interval: float = 3.0,
) -> dict[int, dict]:
@@ -135,7 +135,7 @@ async def poll_document_status(
"/api/v1/documents/status",
headers=headers,
params={
- "search_space_id": search_space_id,
+ "workspace_id": workspace_id,
"document_ids": ids_param,
},
)
@@ -199,15 +199,15 @@ async def get_notifications(
headers: dict[str, str],
*,
type_filter: str | None = None,
- search_space_id: int | None = None,
+ workspace_id: int | None = None,
limit: int = 50,
) -> list[dict]:
"""Fetch notifications for the authenticated user, optionally filtered by type."""
params: dict[str, str | int] = {"limit": limit}
if type_filter:
params["type"] = type_filter
- if search_space_id is not None:
- params["search_space_id"] = search_space_id
+ if workspace_id is not None:
+ params["workspace_id"] = workspace_id
resp = await client.get(
"/api/v1/notifications",
diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock
index bdce64f30..a5b492fc8 100644
--- a/surfsense_backend/uv.lock
+++ b/surfsense_backend/uv.lock
@@ -24,6 +24,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -46,6 +58,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -68,6 +92,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -90,6 +126,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -112,6 +160,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -134,6 +194,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -156,6 +228,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -178,6 +262,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -200,6 +296,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -222,6 +330,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -251,6 +371,22 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -273,6 +409,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -295,6 +443,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -317,6 +477,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -339,6 +511,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -361,6 +545,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -383,6 +579,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -405,6 +613,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -427,6 +647,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -449,6 +681,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -471,6 +715,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -500,6 +756,22 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -522,6 +794,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -544,6 +828,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -566,6 +862,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -588,6 +896,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -610,6 +930,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -632,6 +964,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -654,6 +998,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -676,6 +1032,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -698,6 +1066,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -720,6 +1100,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -749,6 +1141,22 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -771,6 +1179,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -814,6 +1234,30 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -836,6 +1280,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -858,6 +1314,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -901,6 +1369,30 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -944,6 +1436,30 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -966,6 +1482,18 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
]
conflicts = [[
@@ -1711,6 +2239,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918 },
]
+[[package]]
+name = "captchatools"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6b/38/01200f5b8169219002728ccddbad8cc50759e523c0884b7e811092d27352/captchatools-1.5.0.tar.gz", hash = "sha256:755ed685a93f6139608b5277ef0410f8682b3522e98f52d9cc3edff9df38ca12", size = 15282 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/f1/7faaa20ffd68314215da2e3c57076c2c89846ab05464f1b7167dc8a9a80b/captchatools-1.5.0-py3-none-any.whl", hash = "sha256:43b651f7603be5725df94e4d715e0ff770efec44b62bb6076eb9a63516c74d94", size = 16394 },
+]
+
[[package]]
name = "catalogue"
version = "2.0.10"
@@ -3421,24 +3961,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970 },
]
-[[package]]
-name = "firecrawl-py"
-version = "4.21.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "aiohttp" },
- { name = "httpx" },
- { name = "nest-asyncio" },
- { name = "pydantic" },
- { name = "python-dotenv" },
- { name = "requests" },
- { name = "websockets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/6d/6b/8201b737c0667bf70748b86a6fb117aefc648154b4e05c5ee649432cbc3d/firecrawl_py-4.21.0.tar.gz", hash = "sha256:14a7e0967d816c711c3c53325c9371e2f780a787d1e94333a34d8aea7a43a237", size = 174256 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/18/f1/1c0f1e5b33a318d7b9705b9e23c4397253d730e516e3d8a2f6aaea4b71a2/firecrawl_py-4.21.0-py3-none-any.whl", hash = "sha256:4e431f36117b4f2aaae633e747859a91626b0f2c6aaa6b7f86dfb7669a3595eb", size = 217607 },
-]
-
[[package]]
name = "flashrank"
version = "0.2.10"
@@ -4906,19 +5428,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954 },
]
-[[package]]
-name = "linkup-sdk"
-version = "0.13.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "httpx" },
- { name = "pydantic" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/10/fa/d54d7086ceb8e8aa3777ae82f9876ceb7d15a6f583bbebf2fc2bea4cf69c/linkup_sdk-0.13.0.tar.gz", hash = "sha256:dab3f516bb955bdb9dd5815445bfdc7a2c9803dc57c3b4be4038d9e40f3d096a", size = 76440 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4c/b8/9a8be932db54dc673c0a2f033831e9963cb4395352ce5a54a423e2fb58de/linkup_sdk-0.13.0-py3-none-any.whl", hash = "sha256:d4f5f4698cbaf4711a3296473f6030613c9c8ac829c83172a51c34c6e653808a", size = 11750 },
-]
-
[[package]]
name = "litellm"
version = "1.88.1"
@@ -9844,7 +10353,7 @@ wheels = [
[[package]]
name = "surf-new-backend"
-version = "0.0.30"
+version = "0.0.31"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
@@ -9853,6 +10362,7 @@ dependencies = [
{ name = "azure-ai-documentintelligence" },
{ name = "azure-storage-blob" },
{ name = "boto3" },
+ { name = "captchatools" },
{ name = "celery", extra = ["redis"] },
{ name = "chonkie", extra = ["all"] },
{ name = "composio" },
@@ -9868,7 +10378,6 @@ dependencies = [
{ name = "fastapi" },
{ name = "fastapi-users", extra = ["oauth", "sqlalchemy"] },
{ name = "faster-whisper" },
- { name = "firecrawl-py" },
{ name = "fractional-indexing" },
{ name = "github3-py" },
{ name = "gitingest" },
@@ -9882,7 +10391,6 @@ dependencies = [
{ name = "langchain-unstructured" },
{ name = "langgraph" },
{ name = "langgraph-checkpoint-postgres" },
- { name = "linkup-sdk" },
{ name = "litellm" },
{ name = "llama-cloud-services" },
{ name = "markdown" },
@@ -9923,7 +10431,6 @@ dependencies = [
{ name = "starlette" },
{ name = "static-ffmpeg" },
{ name = "stripe" },
- { name = "tavily-python" },
{ name = "tornado" },
{ name = "trafilatura" },
{ name = "typst" },
@@ -9971,6 +10478,7 @@ requires-dist = [
{ name = "azure-ai-documentintelligence", specifier = ">=1.0.2" },
{ name = "azure-storage-blob", specifier = ">=12.23.0" },
{ name = "boto3", specifier = ">=1.35.0" },
+ { name = "captchatools", specifier = ">=1.5.0" },
{ name = "celery", extras = ["redis"], specifier = ">=5.5.3" },
{ name = "chonkie", extras = ["all"], specifier = ">=1.5.0" },
{ name = "composio", specifier = ">=0.10.9" },
@@ -9986,7 +10494,6 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.8" },
{ name = "fastapi-users", extras = ["oauth", "sqlalchemy"], specifier = ">=15.0.3" },
{ name = "faster-whisper", specifier = ">=1.1.0" },
- { name = "firecrawl-py", specifier = ">=4.9.0" },
{ name = "fractional-indexing", specifier = ">=0.1.3" },
{ name = "github3-py", specifier = "==4.0.1" },
{ name = "gitingest", specifier = ">=0.3.1" },
@@ -10000,7 +10507,6 @@ requires-dist = [
{ name = "langchain-unstructured", specifier = ">=1.0.1" },
{ name = "langgraph", specifier = ">=1.1.3" },
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.0.2" },
- { name = "linkup-sdk", specifier = ">=0.2.4" },
{ name = "litellm", specifier = ">=1.83.7" },
{ name = "llama-cloud-services", specifier = ">=0.6.25" },
{ name = "markdown", specifier = ">=3.7" },
@@ -10041,7 +10547,6 @@ requires-dist = [
{ name = "starlette", specifier = ">=0.40.0,<0.51.0" },
{ name = "static-ffmpeg", specifier = ">=2.13" },
{ name = "stripe", specifier = ">=15.0.0" },
- { name = "tavily-python", specifier = ">=0.3.2" },
{ name = "torch", marker = "sys_platform == 'linux' and extra == 'cpu'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "surf-new-backend", extra = "cpu" } },
{ name = "torch", marker = "sys_platform == 'linux' and extra == 'cu126'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu126", conflict = { package = "surf-new-backend", extra = "cu126" } },
{ name = "torch", marker = "sys_platform == 'linux' and extra == 'cu128'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128", conflict = { package = "surf-new-backend", extra = "cu128" } },
@@ -10095,20 +10600,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814 },
]
-[[package]]
-name = "tavily-python"
-version = "0.7.23"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "httpx" },
- { name = "requests" },
- { name = "tiktoken" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/89/d1/197419d6133643848514e5e84e8f41886e825b73bf91ae235a1595c964f5/tavily_python-0.7.23.tar.gz", hash = "sha256:3b92232e0e29ab68898b765f281bb4f2c650b02210b64affbc48e15292e96161", size = 25968 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/64/27/f9c6e9249367be0772fb754849e03cbbc6ad8d80a479bf30ea8811828b2e/tavily_python-0.7.23-py3-none-any.whl", hash = "sha256:52ef85c44b926bce3f257570cd32bc1bd4db54666acf3105617f27411a59e188", size = 19079 },
-]
-
[[package]]
name = "tenacity"
version = "9.1.4"
diff --git a/surfsense_browser_extension/background/messages/savedata.ts b/surfsense_browser_extension/background/messages/savedata.ts
index 4bdca07fd..065b4d951 100644
--- a/surfsense_browser_extension/background/messages/savedata.ts
+++ b/surfsense_browser_extension/background/messages/savedata.ts
@@ -112,12 +112,12 @@ const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
}));
const token = await storage.get("token");
- const search_space_id = parseInt(await storage.get("search_space_id"), 10);
+ const workspace_id = parseInt(await storage.get("workspace_id"), 10);
const toSend = {
document_type: "EXTENSION",
content: content,
- search_space_id: search_space_id,
+ workspace_id: workspace_id,
};
console.log("toSend", toSend);
diff --git a/surfsense_browser_extension/background/messages/savesnapshot.ts b/surfsense_browser_extension/background/messages/savesnapshot.ts
index 8928e1b59..7bf0c0c84 100644
--- a/surfsense_browser_extension/background/messages/savesnapshot.ts
+++ b/surfsense_browser_extension/background/messages/savesnapshot.ts
@@ -106,12 +106,12 @@ const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
}));
const token = await storage.get("token");
- const search_space_id = parseInt(await storage.get("search_space_id"), 10);
+ const workspace_id = parseInt(await storage.get("workspace_id"), 10);
const toSend = {
document_type: "EXTENSION",
content: content,
- search_space_id: search_space_id,
+ workspace_id: workspace_id,
};
const requestOptions = {
diff --git a/surfsense_browser_extension/package.json b/surfsense_browser_extension/package.json
index 728909d06..8dda0d82f 100644
--- a/surfsense_browser_extension/package.json
+++ b/surfsense_browser_extension/package.json
@@ -1,7 +1,7 @@
{
"name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension",
- "version": "0.0.30",
+ "version": "0.0.31",
"description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter",
"engines": {
diff --git a/surfsense_browser_extension/pnpm-workspace.yaml b/surfsense_browser_extension/pnpm-workspace.yaml
new file mode 100644
index 000000000..23a9a8e9a
--- /dev/null
+++ b/surfsense_browser_extension/pnpm-workspace.yaml
@@ -0,0 +1,7 @@
+allowBuilds:
+ '@parcel/watcher': set this to true or false
+ '@swc/core': set this to true or false
+ esbuild: set this to true or false
+ lmdb: set this to true or false
+ msgpackr-extract: set this to true or false
+ sharp: set this to true or false
diff --git a/surfsense_browser_extension/routes/pages/HomePage.tsx b/surfsense_browser_extension/routes/pages/HomePage.tsx
index 9d8787d29..ab2f6be42 100644
--- a/surfsense_browser_extension/routes/pages/HomePage.tsx
+++ b/surfsense_browser_extension/routes/pages/HomePage.tsx
@@ -33,6 +33,20 @@ import { getRenderedHtml } from "~utils/commons";
import type { WebHistory } from "~utils/interfaces";
import Loading from "./Loading";
+// One-time migration: legacy persisted keys were `search_space` / `search_space_id`.
+// Copy them to the new `workspace` / `workspace_id` keys on first read so the
+// user's selected workspace survives the rename.
+async function migrateLegacyWorkspaceKeys(storage: Storage): Promise {
+ const legacyName = await storage.get("search_space");
+ if (legacyName !== undefined && (await storage.get("workspace")) === undefined) {
+ await storage.set("workspace", legacyName);
+ }
+ const legacyId = await storage.get("search_space_id");
+ if (legacyId !== undefined && (await storage.get("workspace_id")) === undefined) {
+ await storage.set("workspace_id", legacyId);
+ }
+}
+
const HomePage = () => {
const { toast } = useToast();
const navigation = useNavigate();
@@ -40,11 +54,11 @@ const HomePage = () => {
const [loading, setLoading] = useState(true);
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
- const [searchspaces, setSearchSpaces] = useState([]);
+ const [workspaces, setWorkspaces] = useState([]);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
- const checkSearchSpaces = async () => {
+ const checkWorkspaces = async () => {
const storage = new Storage({ area: "local" });
const token = await storage.get("token");
@@ -55,7 +69,7 @@ const HomePage = () => {
}
try {
- const response = await fetch(await buildBackendUrl("/api/v1/searchspaces"), {
+ const response = await fetch(await buildBackendUrl("/api/v1/workspaces"), {
headers: {
Authorization: `Bearer ${token}`,
}
@@ -66,7 +80,7 @@ const HomePage = () => {
} else {
const res = await response.json();
console.log(res);
- setSearchSpaces(res);
+ setWorkspaces(res);
}
} catch (error) {
await storage.remove("token");
@@ -77,7 +91,7 @@ const HomePage = () => {
}
};
- checkSearchSpaces();
+ checkWorkspaces();
}, []);
useEffect(() => {
@@ -98,10 +112,11 @@ const HomePage = () => {
});
const storage = new Storage({ area: "local" });
- const searchspace = await storage.get("search_space");
+ await migrateLegacyWorkspaceKeys(storage);
+ const workspace = await storage.get("workspace");
- if (searchspace) {
- setValue(searchspace);
+ if (workspace) {
+ setValue(workspace);
}
await storage.set("showShadowDom", true);
@@ -262,17 +277,17 @@ const HomePage = () => {
const saveDatamessage = async () => {
if (value === "") {
toast({
- title: "Select a SearchSpace !",
+ title: "Select a Workspace !",
});
return;
}
const storage = new Storage({ area: "local" });
- const search_space_id = await storage.get("search_space_id");
+ const workspace_id = await storage.get("workspace_id");
- if (!search_space_id) {
+ if (!workspace_id) {
toast({
- title: "Invalid SearchSpace selected!",
+ title: "Invalid Workspace selected!",
variant: "destructive",
});
return;
@@ -319,15 +334,15 @@ const HomePage = () => {
const storage = new Storage({ area: "local" });
await storage.remove("token");
await storage.remove("showShadowDom");
- await storage.remove("search_space");
- await storage.remove("search_space_id");
+ await storage.remove("workspace");
+ await storage.remove("workspace_id");
navigation("/login");
}
if (loading) {
return ;
} else {
- return searchspaces.length === 0 ? (
+ return workspaces.length === 0 ? (
@@ -337,7 +352,7 @@ const HomePage = () => {
SurfSense
-
Please create a Search Space to continue
+
Please create a Workspace to continue
@@ -390,7 +405,7 @@ const HomePage = () => {
-
Search Space
+
Workspace
{
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
>
{value
- ? searchspaces.find((space) => space.name === value)?.name
- : "Select Search Space..."}
+ ? workspaces.find((space) => space.name === value)?.name
+ : "Select Workspace..."}
@@ -411,23 +426,23 @@ const HomePage = () => {
className="border-gray-700 bg-gray-900 text-gray-200"
/>
- No search spaces found.
+ No workspaces found.
- {searchspaces.map((space) => (
+ {workspaces.map((space) => (
{
const storage = new Storage({ area: "local" });
if (currentValue === value) {
- await storage.set("search_space", "");
- await storage.set("search_space_id", 0);
+ await storage.set("workspace", "");
+ await storage.set("workspace_id", 0);
} else {
- const selectedSpace = searchspaces.find(
+ const selectedSpace = workspaces.find(
(space) => space.name === currentValue
);
- await storage.set("search_space", currentValue);
- await storage.set("search_space_id", selectedSpace.id);
+ await storage.set("workspace", currentValue);
+ await storage.set("workspace_id", selectedSpace.id);
}
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
diff --git a/surfsense_browser_extension/tsconfig.tsbuildinfo b/surfsense_browser_extension/tsconfig.tsbuildinfo
new file mode 100644
index 000000000..85318fb9e
--- /dev/null
+++ b/surfsense_browser_extension/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":["../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.string.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.regexp.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.array.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.collection.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.collection.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.object.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.promise.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.regexp.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.string.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.array.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.collection.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.intl.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.disposable.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.promise.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.decorators.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.iterator.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.float16.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.error.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.d.ts","../surfsense_web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/.pnpm/@types+react@18.2.48/node_modules/@types/react/global.d.ts","./node_modules/.pnpm/csstype@3.1.3/node_modules/csstype/index.d.ts","./node_modules/.pnpm/@types+prop-types@15.7.12/node_modules/@types/prop-types/index.d.ts","./node_modules/.pnpm/@types+react@18.2.48/node_modules/@types/react/index.d.ts","./node_modules/.pnpm/plasmo@0.90.5_@swc+core@1.7.14_@swc+helpers@0.5.12__@swc+helpers@0.5.12_@types+node@20._d660f477c46523e758ec011d637c233c/node_modules/plasmo/templates/plasmo.d.ts","./node_modules/.pnpm/@types+react-dom@18.2.18/node_modules/@types/react-dom/client.d.ts","./node_modules/.pnpm/plasmo@0.90.5_@swc+core@1.7.14_@swc+helpers@0.5.12__@swc+helpers@0.5.12_@types+node@20._d660f477c46523e758ec011d637c233c/node_modules/plasmo/dist/type.d.ts","./content.ts","./node_modules/.pnpm/@plasmohq+storage@1.11.0_react@18.2.0/node_modules/@plasmohq/storage/dist/index.d.ts","./utils/interfaces.ts","./utils/commons.ts","./background/index.ts","./node_modules/.pnpm/@plasmohq+messaging@0.6.2_react@18.2.0/node_modules/@plasmohq/messaging/dist/types-a2e5594b.d.ts","./node_modules/.pnpm/@plasmohq+messaging@0.6.2_react@18.2.0/node_modules/@plasmohq/messaging/dist/index.d.ts","./utils/backend-url.ts","./background/messages/savedata.ts","./node_modules/.pnpm/dom-to-semantic-markdown@1.2.11/node_modules/dom-to-semantic-markdown/dist/node/types/markdowntypes.d.ts","./node_modules/.pnpm/dom-to-semantic-markdown@1.2.11/node_modules/dom-to-semantic-markdown/dist/node/core/htmltomarkdownast.d.ts","./node_modules/.pnpm/dom-to-semantic-markdown@1.2.11/node_modules/dom-to-semantic-markdown/dist/node/core/markdownasttostring.d.ts","./node_modules/.pnpm/dom-to-semantic-markdown@1.2.11/node_modules/dom-to-semantic-markdown/dist/node/core/domutils.d.ts","./node_modules/.pnpm/dom-to-semantic-markdown@1.2.11/node_modules/dom-to-semantic-markdown/dist/node/core/urlutils.d.ts","./node_modules/.pnpm/dom-to-semantic-markdown@1.2.11/node_modules/dom-to-semantic-markdown/dist/node/index.d.ts","./background/messages/savesnapshot.ts","./node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/clsx.d.ts","./node_modules/.pnpm/tailwind-merge@2.5.4/node_modules/tailwind-merge/dist/types.d.ts","./lib/utils.ts","./node_modules/.pnpm/@remix-run+router@1.19.1/node_modules/@remix-run/router/dist/history.d.ts","./node_modules/.pnpm/@remix-run+router@1.19.1/node_modules/@remix-run/router/dist/utils.d.ts","./node_modules/.pnpm/@remix-run+router@1.19.1/node_modules/@remix-run/router/dist/router.d.ts","./node_modules/.pnpm/@remix-run+router@1.19.1/node_modules/@remix-run/router/dist/index.d.ts","./node_modules/.pnpm/react-router@6.26.1_react@18.2.0/node_modules/react-router/dist/lib/context.d.ts","./node_modules/.pnpm/react-router@6.26.1_react@18.2.0/node_modules/react-router/dist/lib/components.d.ts","./node_modules/.pnpm/react-router@6.26.1_react@18.2.0/node_modules/react-router/dist/lib/hooks.d.ts","./node_modules/.pnpm/react-router@6.26.1_react@18.2.0/node_modules/react-router/dist/index.d.ts","./node_modules/.pnpm/react-router-dom@6.26.1_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/react-router-dom/dist/dom.d.ts","./node_modules/.pnpm/react-router-dom@6.26.1_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/react-router-dom/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-primitive@2.0.0_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom_554662aa8056d3d6160ccb0686da9bae/node_modules/@radix-ui/react-primitive/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-dismissable-layer@1.1.1_@types+react-dom@18.2.18_@types+react@18.2.48_r_ae4c82b13c079e215addea70a4e3d8f4/node_modules/@radix-ui/react-dismissable-layer/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-toast@1.2.2_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/@radix-ui/react-toast/dist/index.d.ts","./node_modules/.pnpm/clsx@2.0.0/node_modules/clsx/clsx.d.ts","./node_modules/.pnpm/class-variance-authority@0.7.0/node_modules/class-variance-authority/dist/types.d.ts","./node_modules/.pnpm/class-variance-authority@0.7.0/node_modules/class-variance-authority/dist/index.d.ts","./node_modules/.pnpm/lucide-react@0.454.0_react@18.2.0/node_modules/lucide-react/dist/lucide-react.d.ts","./routes/ui/toast.tsx","./routes/ui/use-toast.tsx","./routes/ui/toaster.tsx","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/types.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/accessibilityicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/activitylogicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/alignbaselineicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/alignbottomicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/aligncenterhorizontallyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/aligncenterverticallyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/alignlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/alignrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/aligntopicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/allsidesicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/angleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/archiveicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/arrowbottomlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/arrowbottomrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/arrowdownicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/arrowlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/arrowrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/arrowtoplefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/arrowtoprighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/arrowupicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/aspectratioicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/avataricon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/backpackicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/badgeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/barcharticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/bellicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/blendingmodeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/bookmarkicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/bookmarkfilledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderallicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderbottomicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderdashedicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderdottedicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/bordernoneicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/bordersolidicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderspliticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderstyleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/bordertopicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/borderwidthicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/boxicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/boxmodelicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/buttonicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/calendaricon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cameraicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cardstackicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cardstackminusicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cardstackplusicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/caretdownicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/caretlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/caretrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/caretsorticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/caretupicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/chatbubbleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/checkicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/checkcircledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/checkboxicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/chevrondownicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/chevronlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/chevronrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/chevronupicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/circleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/circlebackslashicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/clipboardicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/clipboardcopyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/clockicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/codeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/codesandboxlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/colorwheelicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/columnspacingicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/columnsicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/commiticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/component1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/component2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/componentbooleanicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/componentinstanceicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/componentnoneicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/componentplaceholdericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/containericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cookieicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/copyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cornerbottomlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cornerbottomrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cornertoplefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cornertoprighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cornersicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/countdowntimericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/counterclockwiseclockicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cropicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cross1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cross2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/crosscircledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/crosshair1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/crosshair2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/crumpledpapericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cubeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cursorarrowicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/cursortexticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dashicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dashboardicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/desktopicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dimensionsicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/discicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/discordlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dividerhorizontalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dividerverticalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/doticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dotfilledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dotshorizontalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dotsverticalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/doublearrowdownicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/doublearrowlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/doublearrowrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/doublearrowupicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/downloadicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/draghandledots1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/draghandledots2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/draghandlehorizontalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/draghandleverticalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/drawingpinicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/drawingpinfilledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/dropdownmenuicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/entericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/enterfullscreenicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/envelopeclosedicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/envelopeopenicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/erasericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/exclamationtriangleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/exiticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/exitfullscreenicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/externallinkicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/eyeclosedicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/eyenoneicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/eyeopenicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/faceicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/figmalogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fileicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fileminusicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fileplusicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/filetexticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fontboldicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fontfamilyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fontitalicicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fontromanicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fontsizeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/fontstyleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/frameicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/framerlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/gearicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/githublogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/globeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/gridicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/groupicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/half1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/half2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/hamburgermenuicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/handicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/headingicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/hearticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/heartfilledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/heighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/hobbyknifeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/homeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/iconjarlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/idcardicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/imageicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/infocircledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/inputicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/instagramlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/keyboardicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/laptimericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/laptopicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/layersicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/layouticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lettercasecapitalizeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lettercaselowercaseicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lettercasetoggleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lettercaseuppercaseicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/letterspacingicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lightningbolticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lineheighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/link1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/link2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/linkbreak1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/linkbreak2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/linknone1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/linknone2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/linkedinlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/listbulleticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lockclosedicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lockopen1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/lockopen2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/loopicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/magicwandicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/magnifyingglassicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/marginicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/maskofficon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/maskonicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/minusicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/minuscircledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/mixicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/mixerhorizontalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/mixerverticalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/mobileicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/modulzlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/moonicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/moveicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/notionlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/opacityicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/openinnewwindowicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/overlineicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/paddingicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/paperplaneicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pauseicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pencil1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pencil2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/personicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/piecharticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pilcrowicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pinbottomicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pinlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pinrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pintopicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/playicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/plusicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/pluscircledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/questionmarkicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/questionmarkcircledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/quoteicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/radiobuttonicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/readericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/reloadicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/reseticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/resumeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/rocketicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/rotatecounterclockwiseicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/rowspacingicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/rowsicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/rulerhorizontalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/rulersquareicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/scissorsicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/sectionicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/sewingpinicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/sewingpinfilledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/shadowicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/shadowinnericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/shadownoneicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/shadowoutericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/share1icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/share2icon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/shuffleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/sizeicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/sketchlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/slashicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/slidericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/spacebetweenhorizontallyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/spacebetweenverticallyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/spaceevenlyhorizontallyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/spaceevenlyverticallyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/speakerloudicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/speakermoderateicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/speakerofficon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/speakerquieticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/squareicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/stackicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/staricon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/starfilledicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/stitcheslogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/stopicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/stopwatchicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/stretchhorizontallyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/stretchverticallyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/strikethroughicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/sunicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/switchicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/symbolicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/tableicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/targeticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/texticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/textalignbottomicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/textaligncentericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/textalignjustifyicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/textalignlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/textalignmiddleicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/textalignrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/textaligntopicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/textnoneicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/thickarrowdownicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/thickarrowlefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/thickarrowrighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/thickarrowupicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/timericon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/tokensicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/tracknexticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/trackpreviousicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/transformicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/transparencygridicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/trashicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/triangledownicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/trianglelefticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/trianglerighticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/triangleupicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/twitterlogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/underlineicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/updateicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/uploadicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/valueicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/valuenoneicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/vercellogoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/videoicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/viewgridicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/viewhorizontalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/viewnoneicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/viewverticalicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/widthicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/zoominicon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/zoomouticon.d.ts","./node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@18.2.0/node_modules/@radix-ui/react-icons/dist/index.d.ts","./node_modules/.pnpm/@types+react@18.2.48/node_modules/@types/react/jsx-runtime.d.ts","./node_modules/.pnpm/@radix-ui+react-slot@1.1.0_@types+react@18.2.48_react@18.2.0/node_modules/@radix-ui/react-slot/dist/index.d.ts","./routes/ui/button.tsx","./node_modules/.pnpm/@radix-ui+react-focus-scope@1.1.0_@types+react-dom@18.2.18_@types+react@18.2.48_react-d_9777f23c60de13e004445273c5cb2434/node_modules/@radix-ui/react-focus-scope/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-portal@1.1.2_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/@radix-ui/react-portal/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-dialog@1.1.2_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/@radix-ui/react-dialog/dist/index.d.ts","./routes/ui/dialog.tsx","./node_modules/.pnpm/@radix-ui+react-primitive@2.1.3_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom_e2daec1b538e67f798adbbf1ab062a22/node_modules/@radix-ui/react-primitive/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-label@2.1.7_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/@radix-ui/react-label/dist/index.d.ts","./routes/ui/label.tsx","./routes/ui/connection-settings-button.tsx","./routes/pages/apikeyform.tsx","./node_modules/.pnpm/cmdk@1.0.3_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/cmdk/dist/index.d.ts","./routes/ui/command.tsx","./node_modules/.pnpm/@radix-ui+react-arrow@1.1.0_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/@radix-ui/react-arrow/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+rect@1.1.0/node_modules/@radix-ui/rect/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-popper@1.2.0_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/@radix-ui/react-popper/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-popover@1.1.2_@types+react-dom@18.2.18_@types+react@18.2.48_react-dom@18.2.0_react@18.2.0__react@18.2.0/node_modules/@radix-ui/react-popover/dist/index.d.ts","./routes/ui/popover.tsx","./routes/pages/loading.tsx","./routes/pages/homepage.tsx","./routes/index.tsx","./popup.tsx","./node_modules/.pnpm/@types+har-format@1.2.15/node_modules/@types/har-format/index.d.ts","./node_modules/.pnpm/@types+chrome@0.0.258/node_modules/@types/chrome/har-format/index.d.ts","./node_modules/.pnpm/@types+chrome@0.0.258/node_modules/@types/chrome/chrome-cast/index.d.ts","./node_modules/.pnpm/@types+filewriter@0.0.33/node_modules/@types/filewriter/index.d.ts","./node_modules/.pnpm/@types+filesystem@0.0.36/node_modules/@types/filesystem/index.d.ts","./node_modules/.pnpm/@types+chrome@0.0.258/node_modules/@types/chrome/index.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/assert.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/assert/strict.d.ts","./node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/header.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/readable.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/file.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/fetch.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/formdata.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/connector.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/client.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/errors.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/dispatcher.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/global-dispatcher.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/global-origin.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/pool-stats.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/pool.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/handlers.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/balanced-pool.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/agent.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/mock-interceptor.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/mock-agent.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/mock-client.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/mock-pool.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/mock-errors.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/proxy-agent.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/api.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/cookies.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/patch.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/filereader.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/websocket.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/content-type.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/cache.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/interceptors.d.ts","./node_modules/.pnpm/undici-types@5.26.5/node_modules/undici-types/index.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/globals.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/async_hooks.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/buffer.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/child_process.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/cluster.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/console.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/constants.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/crypto.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/dgram.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/dns.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/dns/promises.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/domain.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/dom-events.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/events.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/fs.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/fs/promises.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/http.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/http2.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/https.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/inspector.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/module.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/net.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/os.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/path.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/perf_hooks.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/process.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/punycode.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/querystring.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/readline.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/readline/promises.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/repl.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/stream.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/stream/promises.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/stream/consumers.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/stream/web.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/string_decoder.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/test.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/timers.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/timers/promises.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/tls.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/trace_events.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/tty.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/url.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/util.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/v8.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/vm.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/wasi.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/worker_threads.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/zlib.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/globals.global.d.ts","./node_modules/.pnpm/@types+node@20.11.5/node_modules/@types/node/index.d.ts","./node_modules/.pnpm/@types+react-dom@18.2.18/node_modules/@types/react-dom/index.d.ts"],"fileIdsList":[[91,92,93],[91,93,96,97],[91,92,93,96,97,104],[89],[106,107],[95],[86,119],[86,119,120,452,453],[86,129],[130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447],[86],[86,456],[86,119,120,452,453,465],[86,119,463,464],[86,449],[86,119,120],[109,110,111],[109,110],[109],[472],[473,474,476],[475],[478],[514],[515,520,548],[516,527,528,535,545,556],[516,517,527,535],[518,557],[519,520,528,536],[520,545,553],[521,523,527,535],[514,522],[523,524],[527],[525,527],[514,527],[527,528,529,545,556],[527,528,529,542,545,548],[512,515,561],[523,527,530,535,545,556],[527,528,530,531,535,545,553,556],[530,532,545,553,556],[478,479,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563],[527,533],[534,556,561],[523,527,535,545],[536],[537],[514,538],[539,555,561],[540],[541],[527,542,543],[542,544,557,559],[515,527,545,546,547,548],[515,545,547],[545,546],[548],[549],[514,545],[527,551,552],[551,552],[520,535,545,553],[554],[535,555],[515,530,541,556],[520,557],[545,558],[534,559],[560],[515,520,527,529,538,545,556,559,561],[545,562],[83,84,85],[122,123],[122],[86,119,454],[99],[99,100,101,102,103],[86,88],[112],[86,112,116,117],[112,113,114,115],[86,112,113],[86,112],[489,493,556],[489,545,556],[484],[486,489,553,556],[535,553],[564],[484,564],[486,489,535,556],[481,482,485,488,515,527,545,556],[481,487],[485,489,515,548,556,564],[515,564],[505,515,564],[483,484,564],[489],[483,484,485,486,487,488,489,490,491,493,494,495,496,497,498,499,500,501,502,503,504,506,507,508,509,510,511],[489,496,497],[487,489,497,498],[488],[481,484,489],[489,493,497,498],[493],[487,489,492,556],[481,486,487,489,493,496],[515,545],[484,489,505,515,561,564],[118,128,470],[118,460,469],[86,87,91,97,118,448,451,459],[86,87,91,92,93,96,97,104,108,118,125,127,448,451,458,459,462,467,468],[87,448],[86,108,124,450],[86,108,125,454,455,461],[86,97,448,451,455,458],[86,108,125,454],[86,108,457],[86,108,466],[86,108,121,124,125],[126,127],[86,126],[91],[91,92]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"0bd5e7096c7bc02bf70b2cc017fc45ef489cb19bd2f32a71af39ff5787f1b56a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8a8eb4ebffd85e589a1cc7c178e291626c359543403d58c9cd22b81fab5b1fb9","impliedFormat":1},{"version":"247a952efd811d780e5630f8cfd76f495196f5fa74f6f0fee39ac8ba4a3c9800","impliedFormat":1},{"version":"e6f3077b1780226627f76085397d10c77a4d851c7154fd4b3f1eb114f4c2e56d","affectsGlobalScope":true,"impliedFormat":1},{"version":"97ae124daa3695481c12e517f1aadb524b12fdb1f09d67f9ecc3328c12271487","affectsGlobalScope":true,"impliedFormat":99},{"version":"c83e65334a9dc08a338f994a34bd70328c626976881d71d6aaa8dc7d66b08d96","impliedFormat":1},{"version":"032a6d45b6d1d730573abb352e355f826f42a412f02f5f37b7183df04c519bf0","impliedFormat":99},"4f83ace641bc115f8346dc57b6b15821a41f0f83ebf82b7ccea3179ae083736a",{"version":"623e835c6beebf5e3350817eea997722da5e2860811b811b331fc42feaceb903","impliedFormat":99},"e2f63ddf0102d5bfd0f3691d678bacd98fd0752b23d19890308c4210b3a5a0a1","8d8528d595e2639b740e4a0b0cf9de23a93ecf39468b707b5dcfc2b5bb4fcfd9","2aabb4e960469b8d854ad33765002f470fdc202cd432812ffb4f0e0196de2cc0",{"version":"197013e03570f313d88acecf2cb444c7bfcbe453149e0f58be04a123386b1e89","impliedFormat":99},{"version":"01e88b933f0e8c8fefa47e910415cded8a1ef4121420cb0fc0cbacf3c933e77f","impliedFormat":99},"35d32d432d95a06a14b7341a8c5fe3392aaf15be4d5f7b720fdaf064636eca75","7c35eee2b4295c21b183a258a1eeaa48b3c0f7a8011eed6c27159a775ad7a9c8",{"version":"4e413cb1023c7371d3a7c54483ef1db0dedeba61ea9241ec84dd38af256cb276","impliedFormat":1},{"version":"df87de0a20d44995d54020ad8826f65e6d9351ae1da427cde3a8e37e1e836084","impliedFormat":1},{"version":"d617f071c8ed193058b01b7b6d563891afd3fa852a03dd6a6fe261a70e241c26","impliedFormat":1},{"version":"978965b7e76bd3b7b825464ba943914513c530812d4760bfb5e394fa76f1dd86","impliedFormat":1},{"version":"ea68cc195306e209d421051a5b344b6b4819661876d9b670f5d4a2f18c95e764","impliedFormat":1},{"version":"61e43dd6b6fbed4605b0f9dc887ce2b32a7bcc327f09246061c182eecdeb5bbf","impliedFormat":1},"6761506c173d972866bf918ccee538e7702ad6f0f126d8c55399cff9ffc01236",{"version":"ef73bcfef9907c8b772a30e5a64a6bd86a5669cba3d210fcdcc6b625e3312459","impliedFormat":1},{"version":"ab90a99fb09a5dd94d80d697b6833b041239843e15ebd634857853240db7e25f","impliedFormat":1},"0fca478853f3fc4310e40ebff727a53da7ec677938f4e3e0d5ecaa62e936a199",{"version":"5af7c35a9c5c4760fd084fedb6ba4c7059ac9598b410093e5312ac10616bf17b","impliedFormat":1},{"version":"18f14a4e6f7e7fa7977ca2479920b375edbd3d9470af09349f1fd5b902948d24","impliedFormat":1},{"version":"386b110097b55a1ad6ee83ce24da8132d74871c32ffd1cc6d8da27fbd937530e","impliedFormat":1},{"version":"3c199753ff81a62b08fc48875cff98776e13b447d2eb79155669f2aa6df2ddda","impliedFormat":1},{"version":"0607c362365f0d2f53fa84d64e9ca3550f3b7e8f1e24d692d8fc5b64d4b7706a","impliedFormat":1},{"version":"86645dd2134c0061c46c9c8959b664e47e897799cfd04b0b3f550956fcaddb26","impliedFormat":1},{"version":"62ea49d20b53246d9b798ae78b60cfd42ff2637ae490ab28cf370b5e64f58080","impliedFormat":1},{"version":"a7be9824c37815e8d0ad066a3a4c1b2c1c4e7176423605ff52943d0fb1bc7c9a","impliedFormat":1},{"version":"0acf888a4886434586cd034594852509e902936e10da93823758c55f23a2a4b5","impliedFormat":1},{"version":"4174cc2d5ba415d241da72122c1693aa46b271846ee12f5b410d5f80a93426d8","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea3dc94b0fffe98fdf557e22b26282b039aa8c3cbbf872d0087d982d1a1f496c","impliedFormat":1},{"version":"7ec047b73f621c526468517fea779fec2007dd05baa880989def59126c98ef79","impliedFormat":1},{"version":"11d84eef6662ddfd17c1154a56d9e71d61ae3c541a61e3bc50f875cb5954379f","impliedFormat":1},{"version":"791b7d18616176562896692cdeff84662d2b2ffe3fc33fce2ce338eaa8a8288e","impliedFormat":1},{"version":"571b2640f0cf541dfed72c433706ad1c70fb55ed60763343aa617e150fbb036e","impliedFormat":1},{"version":"6a2372186491f911527a890d92ac12b88dec29f1c0cec7fce93745aba3253fde","impliedFormat":1},{"version":"8512cce0256f2fcad4dc4d72a978ef64fefab78c16a1141b31f2f2eba48823d1","impliedFormat":1},"5ad9aaf78466035e6e5a072d652d63313af7891f16208fcfbf1a596491d34e36","6037515cc66eca2db6b21889465e7df16f0350becab8c162e17715a306cfce15","359ee99d27002dc85a010d08e09326aaa1d3212afae8b75d5495f87d55b70fcc",{"version":"a58825dfef3de2927244c5337ff2845674d1d1a794fb76d37e1378e156302b90","impliedFormat":1},{"version":"1a458765deab35824b11b67f22b1a56e9a882da9f907bfbf9ce0dfaedc11d8fc","impliedFormat":1},{"version":"a48553595da584120091fb7615ed8d3b48aaea4b2a7f5bc5451c1247110be41a","impliedFormat":1},{"version":"ebba1c614e81bf35da8d88a130e7a2924058a9ad140abe79ef4c275d4aa47b0d","impliedFormat":1},{"version":"3f3cfb6d0795d076c62fca9fa90e61e1a1dd9ba1601cd28b30b21af0b989b85a","impliedFormat":1},{"version":"2647c7b6ad90f146f26f3cdf0477eed1cefb1826e8de3f61c584cc727e2e4496","impliedFormat":1},{"version":"891faf74d5399bee0d216314ecf7a0000ba56194ffd16b2b225e4e61706192fb","impliedFormat":1},{"version":"c1227e0b571469c249e7b152e98268b3ccdfd67b5324f55448fad877ba6dbbff","impliedFormat":1},{"version":"230a4cc1df158d6e6e29567bfa2bc88511822a068da08f8761cc4df5d2328dcc","impliedFormat":1},{"version":"c6ee2448a0c52942198242ec9d05251ff5abfb18b26a27970710cf85e3b62e50","impliedFormat":1},{"version":"39525087f91a6f9a246c2d5c947a90d4b80d67efb96e60f0398226827ae9161e","impliedFormat":1},{"version":"1bf429877d50f454b60c081c00b17be4b0e55132517ac322beffe6288b6e7cf6","impliedFormat":1},{"version":"b139b4ed2c853858184aed5798880633c290b680d22aee459b1a7cf9626a540d","impliedFormat":1},{"version":"037a9dab60c22cda0cd6c502a27b2ecfb1ac5199efe5e8c8d939591f32bd73c9","impliedFormat":1},{"version":"a21eaf3dc3388fae4bdd0556eb14c9e737e77b6f1b387d68c3ed01ca05439619","impliedFormat":1},{"version":"60931d8fb8f91afacbb005180092f4f745d2af8b8a9c0957c44c42409ec758e7","impliedFormat":1},{"version":"70e88656db130df927e0c98edcdb4e8beeb2779ac0e650b889ab3a1a3aa71d3d","impliedFormat":1},{"version":"a6473d7b874c3cffc1cb18f5d08dd18ac880b97ec0a651348739ade3b3730272","impliedFormat":1},{"version":"89720b54046b31371a2c18f7c7a35956f1bf497370f4e1b890622078718875b1","impliedFormat":1},{"version":"281637d0a9a4b617138c505610540583676347c856e414121a5552b9e4aeb818","impliedFormat":1},{"version":"87612b346018721fa0ee2c0cb06de4182d86c5c8b55476131612636aac448444","impliedFormat":1},{"version":"c0b2ae1fea13046b9c66df05dd8d36f9b1c9fcea88d822899339183e6ef1b952","impliedFormat":1},{"version":"8c7b41fd103b70c3a65b7ace9f16cd00570b405916d0e3bd63e9986ce91e6156","impliedFormat":1},{"version":"0e51075b769786db5e581e43a64529dca371040256e23d779603a2c8283af7d6","impliedFormat":1},{"version":"54fd7300c6ba1c98cda49b50c215cde3aa5dbae6786eaf05655abf818000954c","impliedFormat":1},{"version":"01a265adad025aa93f619b5521a9cb08b88f3c328b1d3e59c0394a41e5977d43","impliedFormat":1},{"version":"af6082823144bd943323a50c844b3dc0e37099a3a19e7d15c687cd85b3985790","impliedFormat":1},{"version":"241f5b92543efc1557ddb6c27b4941a5e0bb2f4af8dc5dd250d8ee6ca67ad67c","impliedFormat":1},{"version":"55e8db543ceaedfdd244182b3363613143ca19fc9dbc466e6307f687d100e1c8","impliedFormat":1},{"version":"27de37ad829c1672e5d1adf0c6a5be6587cbe405584e9a9a319a4214b795f83a","impliedFormat":1},{"version":"2d39120fb1d7e13f8141fa089543a817a94102bba05b2b9d14b6f33a97de4e0c","impliedFormat":1},{"version":"51c1a42c27ae22f5a2f7a26afcf9aa8e3fd155ba8ecc081c6199a5ce6239b5f4","impliedFormat":1},{"version":"72fb41649e77c743e03740d1fd8e18c824bd859a313a7caeba6ba313a84a79a9","impliedFormat":1},{"version":"6ee51191c0df1ec11db3fbc71c39a7dee2b3e77dcaab974348eaf04b2f22307d","impliedFormat":1},{"version":"b8a996130883aaffdee89e0a3e241d4674a380bde95f8270a8517e118350def7","impliedFormat":1},{"version":"a3dce310d0bd772f93e0303bb364c09fc595cc996b840566e8ef8df7ab0e5360","impliedFormat":1},{"version":"eb9fa21119013a1c7566d2154f6686c468e9675083ef39f211cd537c9560eb53","impliedFormat":1},{"version":"c6b5695ccff3ceab8c7a1fe5c5e1c37667c8e46b6fc9c3c953d53aa17f6e2e59","impliedFormat":1},{"version":"d08d0d4b4a47cc80dbea459bb1830c15ec8d5d7056742ae5ccc16dd4729047d0","impliedFormat":1},{"version":"975c1ef08d7f7d9a2f7bc279508cc47ddfdfe6186c37ac98acbf302cf20e7bb1","impliedFormat":1},{"version":"bd53b46bab84955dc0f83afc10237036facbc7e086125f81f13fd8e02b43a0d5","impliedFormat":1},{"version":"3c68d3e9cd1b250f52d16d5fbbd40a0ccbbe8b2d9dbd117bfd25acc2e1a60ebc","impliedFormat":1},{"version":"88f4763dddd0f685397f1f6e6e486b0297c049196b3d3531c48743e6334ddfcb","impliedFormat":1},{"version":"8f0ab3468882aba7a39acbc1f3b76589a1ef517bfb2ef62e2dd896f25db7fba6","impliedFormat":1},{"version":"407b6b015a9cf880756296a91142e72b3e6810f27f117130992a1138d3256740","impliedFormat":1},{"version":"0bee9708164899b64512c066ba4de189e6decd4527010cc325f550451a32e5ab","impliedFormat":1},{"version":"2472ae6554b4e997ec35ae5ad5f91ab605f4e30b97af860ced3a18ab8651fb89","impliedFormat":1},{"version":"df0e9f64d5facaa59fca31367be5e020e785335679aa088af6df0d63b7c7b3df","impliedFormat":1},{"version":"07ce90ffcac490edb66dfcb3f09f1ffa7415ecf4845f525272b53971c07ad284","impliedFormat":1},{"version":"801a0aa3e78ef62277f712aefb7455a023063f87577df019dde7412d2bc01df9","impliedFormat":1},{"version":"ab457e1e513214ba8d7d13040e404aea11a3e6e547d10a2cbbd926cccd756213","impliedFormat":1},{"version":"d62fbef71a36476326671f182368aed0d77b6577c607e6597d080e05ce49cf9e","impliedFormat":1},{"version":"2a72354cb43930dc8482bd6f623f948d932250c5358ec502a47e7b060ed3bbb6","impliedFormat":1},{"version":"cff4d73049d4fbcd270f6d2b3a6212bf17512722f8a9dfcc7a3ff1b8a8eef1f0","impliedFormat":1},{"version":"f9a7c0d530affbd3a38853818a8c739fbf042a376b7deca9230e65de7b65ee34","impliedFormat":1},{"version":"c024252e3e524fcebaeed916ccb8ede5d487eb8d705c6080dc009df3c87dd066","impliedFormat":1},{"version":"641448b49461f3e6936e82b901a48f2d956a70e75e20c6a688f8303e9604b2ff","impliedFormat":1},{"version":"0d923bfc7b397b8142db7c351ba6f59f118c4fe820c1e4a0b6641ac4b7ab533d","impliedFormat":1},{"version":"13737fae5d9116556c56b3fc01ffae01f31d77748bc419185514568d43aae9be","impliedFormat":1},{"version":"4224758de259543c154b95f11c683da9ac6735e1d53c05ae9a38835425782979","impliedFormat":1},{"version":"2704fd2c7b0e4df05a072202bfcc87b5e60a228853df055f35c5ea71455def95","impliedFormat":1},{"version":"cb52c3b46277570f9eb2ef6d24a9732c94daf83761d9940e10147ebb28fbbb8e","impliedFormat":1},{"version":"1bc305881078821daa054e3cb80272dc7528e0a51c91bf3b5f548d7f1cf13c2b","impliedFormat":1},{"version":"ba53329809c073b86270ebd0423f6e7659418c5bd48160de23f120c32b5ceccc","impliedFormat":1},{"version":"f0a86f692166c5d2b153db200e84bb3d65e0c43deb8f560e33f9f70045821ec9","impliedFormat":1},{"version":"b163773a303feb2cbfc9de37a66ce0a01110f2fb059bc86ea3475399f2c4d888","impliedFormat":1},{"version":"cf781f174469444530756c85b6c9d297af460bf228380ed65a9e5d38b2e8c669","impliedFormat":1},{"version":"cbe1b33356dbcf9f0e706d170f3edf9896a2abc9bc1be12a28440bdbb48f16b1","impliedFormat":1},{"version":"d8498ad8a1aa7416b1ebfec256149f369c4642b48eca37cd1ea85229b0ca00d6","impliedFormat":1},{"version":"d054294baaab34083b56c038027919d470b5c5b26c639720a50b1814d18c5ee4","impliedFormat":1},{"version":"4532f2906ba87ae0c4a63f572e8180a78fd612da56f54d6d20c2506324158c08","impliedFormat":1},{"version":"878bf2fc1bbed99db0c0aa2f1200af4f2a77913a9ba9aafe80b3d75fd2de6ccc","impliedFormat":1},{"version":"039d6e764bb46e433c29c86be0542755035fc7a93aa2e1d230767dd54d7307c2","impliedFormat":1},{"version":"f80195273b09618979ad43009ca9ad7d01461cce7f000dc5b7516080e1bca959","impliedFormat":1},{"version":"16a7f250b6db202acc93d9f1402f1049f0b3b1b94135b4f65c7a7b770a030083","impliedFormat":1},{"version":"d15e9aaeef9ff4e4f8887060c0f0430b7d4767deafb422b7e474d3a61be541b9","impliedFormat":1},{"version":"777ddacdcb4fb6c3e423d3f020419ae3460b283fc5fa65c894a62dff367f9ad2","impliedFormat":1},{"version":"9a02117e0da8889421c322a2650711788622c28b69ed6d70893824a1183a45a8","impliedFormat":1},{"version":"9e30d7ef1a67ddb4b3f304b5ee2873f8e39ed22e409e1b6374819348c1e06dfa","impliedFormat":1},{"version":"ddeb300b9cf256fb7f11e54ce409f6b862681c96cc240360ab180f2f094c038b","impliedFormat":1},{"version":"0dbdd4be29dfc4f317711269757792ccde60140386721bee714d3710f3fbbd66","impliedFormat":1},{"version":"1f92e3e35de7c7ddb5420320a5f4be7c71f5ce481c393b9a6316c0f3aaa8b5e4","impliedFormat":1},{"version":"b721dc785a4d747a8dabc82962b07e25080e9b194ba945f6ff401782e81d1cef","impliedFormat":1},{"version":"f88b42ae60eb60621eec477610a8f457930af3cb83f0bebc5b6ece0a8cc17126","impliedFormat":1},{"version":"97c89e7e4e301d6db3e35e33d541b8ab9751523a0def016d5d7375a632465346","impliedFormat":1},{"version":"29ab360e8b7560cf55b6fb67d0ed81aae9f787427cf2887378fdecf386887e07","impliedFormat":1},{"version":"009bfb8cd24c1a1d5170ba1c1ccfa946c5082d929d1994dcf80b9ebebe6be026","impliedFormat":1},{"version":"654ee5d98b93d5d1a5d9ad4f0571de66c37367e2d86bae3513ea8befb9ed3cac","impliedFormat":1},{"version":"83c14b1b0b4e3d42e440c6da39065ab0050f1556788dfd241643430d9d870cf3","impliedFormat":1},{"version":"d96dfcef148bd4b06fa3c765c24cb07ff20a264e7f208ec4c5a9cbb3f028a346","impliedFormat":1},{"version":"f65550bf87be517c3178ae5372f91f9165aa2f7fc8d05a833e56edc588331bb0","impliedFormat":1},{"version":"9f4031322535a054dcdd801bc39e2ed1cdeef567f83631af473a4994717358e1","impliedFormat":1},{"version":"e6ef5df7f413a8ede8b53f351aac7138908253d8497a6f3150df49270b1e7831","impliedFormat":1},{"version":"b5b3104513449d4937a542fb56ba0c1eb470713ec351922e7c42ac695618e6a4","impliedFormat":1},{"version":"2b117d7401af4b064388acbb26a745c707cbe3420a599dc55f5f8e0fd8dd5baa","impliedFormat":1},{"version":"7d768eb1b419748eec264eff74b384d3c71063c967ac04c55303c9acc0a6c5dd","impliedFormat":1},{"version":"2f1bf6397cecf50211d082f338f3885d290fb838576f71ed4f265e8c698317f9","impliedFormat":1},{"version":"54f0d5e59a56e6ba1f345896b2b79acf897dfbd5736cbd327d88aafbef26ac28","impliedFormat":1},{"version":"760f3a50c7a9a1bc41e514a3282fe88c667fbca83ce5255d89da7a7ffb573b18","impliedFormat":1},{"version":"e966c134cdad68fb5126af8065a5d6608255ed0e9a008b63cf2509940c13660c","impliedFormat":1},{"version":"64a39a5d4bcbe5c8d9e5d32d7eb22dd35ae12cd89542ecb76567334306070f73","impliedFormat":1},{"version":"c1cc0ffa5bca057cc50256964882f462f714e5a76b86d9e23eb9ff1dfa14768d","impliedFormat":1},{"version":"08ab3ecce59aceee88b0c88eb8f4f8f6931f0cfd32b8ad0e163ef30f46e35283","impliedFormat":1},{"version":"0736d054796bb2215f457464811691bf994c0244498f1bb3119c7f4a73c2f99a","impliedFormat":1},{"version":"23bc9533664545d3ba2681eb0816b3f57e6ed2f8dce2e43e8f36745eafd984d4","impliedFormat":1},{"version":"689cbcf3764917b0a1392c94e26dd7ac7b467d84dc6206e3d71a66a4094bf080","impliedFormat":1},{"version":"a9f4de411d2edff59e85dd16cde3d382c3c490cbde0a984bf15533cfed6a8539","impliedFormat":1},{"version":"e30c1cf178412030c123b16dbbee1d59c312678593a0b3622c9f6d487c7e08ba","impliedFormat":1},{"version":"837033f34e1d4b56eab73998c5a0b64ee97db7f6ee9203c649e4cd17572614d8","impliedFormat":1},{"version":"cc8d033897f386df54c65c97c8bb23cfb6912954aa8128bff472d6f99352bb80","impliedFormat":1},{"version":"ca5820f82654abe3a72170fb04bbbb65bb492c397ecce8df3be87155b4a35852","impliedFormat":1},{"version":"9badb725e63229b86fa35d822846af78321a84de4a363da4fe6b5a3262fa31f2","impliedFormat":1},{"version":"f8e96a237b01a2b696b5b31172339d50c77bef996b225e8be043478a3f4a9be5","impliedFormat":1},{"version":"7d048c0fbdb740ae3fa64225653304fdb8d8bb7d905facf14f62e72f3e0ba21a","impliedFormat":1},{"version":"c59b8fb44e6ad7dc3e80359b43821026730a82d98856b690506ba39b5b03789b","impliedFormat":1},{"version":"bd86b749fb17c6596803ace4cae1b6474d820fd680c157e66d884e7c43ef1b24","impliedFormat":1},{"version":"879ba0ae1e59ec935b82af4f3f5ca62cbddecb3eb750c7f5ab28180d3180ec86","impliedFormat":1},{"version":"14fb829e7830df3e326af086bb665fd8dc383b1da2cde92e8ef67b6c49b13980","impliedFormat":1},{"version":"ec14ef5e67a6522f967a17eeedb0b8214c17b5ae3214f1434fcfa0ea66e25756","impliedFormat":1},{"version":"b38474dee55446b3b65ea107bc05ea15b5b5ca3a5fa534371daed44610181303","impliedFormat":1},{"version":"511db7e798d39b067ea149b0025ad2198cfe13ce284a789ef87f0a629942d52f","impliedFormat":1},{"version":"0e50ecb8433db4570ed22f3f56fd7372ebddb01f4e94346f043eeb42b4ada566","impliedFormat":1},{"version":"2beccefff361c478d57f45279478baeb7b7bcdac48c6108bec3a2d662344e1ea","impliedFormat":1},{"version":"b5c984f3e386c7c7c736ed7667b94d00a66f115920e82e9fa450dc27ccc0301e","impliedFormat":1},{"version":"acdd01e74c36396d3743b0caf0b4c7801297ca7301fa5db8ce7dbced64ec5732","impliedFormat":1},{"version":"82da8b99d0030a3babb7adfe3bb77bc8f89cc7d0737b622f4f9554abdc53cd89","impliedFormat":1},{"version":"80e11385ab5c1b042e02d64c65972fff234806525bf4916a32221d1baebfe2f9","impliedFormat":1},{"version":"a894178e9f79a38124f70afb869468bace08d789925fd22f5f671d9fb2f68307","impliedFormat":1},{"version":"b44237286e4f346a7151d33ff98f11a3582e669e2c08ec8b7def892ad7803f84","impliedFormat":1},{"version":"910c0d9ce9a39acafc16f6ca56bdbdb46c558ef44a9aa1ee385257f236498ee1","impliedFormat":1},{"version":"fed512983a39b9f0c6f1f0f04cc926aca2096e81570ae8cd84cad8c348e5e619","impliedFormat":1},{"version":"2ebf8f17b91314ec8167507ee29ebeb8be62a385348a0b8a1e7f433a7fb2cf89","impliedFormat":1},{"version":"cb48d9c290927137bfbd9cd93f98fca80a3704d0a1a26a4609542a3ab416c638","impliedFormat":1},{"version":"9ab3d74792d40971106685fb08a1c0e4b9b80d41e3408aa831e8a19fedc61ab8","impliedFormat":1},{"version":"394f9d6dc566055724626b455a9b5c86c27eeb1fdbd499c3788ab763585f5c41","impliedFormat":1},{"version":"9bc0ab4b8cb98cd3cb314b341e5aaab3475e5385beafb79706a497ebddc71b5d","impliedFormat":1},{"version":"35433c5ee1603dcac929defe439eec773772fab8e51b10eeb71e6296a44d9acb","impliedFormat":1},{"version":"aeee9ba5f764cea87c2b9905beb82cfdf36f9726f8dea4352fc233b308ba2169","impliedFormat":1},{"version":"35ea8672448e71ffa3538648f47603b4f872683e6b9db63168d7e5e032e095ef","impliedFormat":1},{"version":"8e63b8db999c7ad92c668969d0e26d486744175426157964771c65580638740d","impliedFormat":1},{"version":"f9da6129c006c79d6029dc34c49da453b1fe274e3022275bcdecaa02895034a0","impliedFormat":1},{"version":"2e9694d05015feb762a5dc7052dd51f66f692c07394b15f6aff612a9fb186f60","impliedFormat":1},{"version":"f570c4e30ea43aecf6fc7dc038cf0a964cf589111498b7dd735a97bf17837e3a","impliedFormat":1},{"version":"cdad25d233b377dd852eaa9cf396f48d916c1f8fd2193969fcafa8fe7c3387cb","impliedFormat":1},{"version":"243b9e4bcd123a332cb99e4e7913114181b484c0bb6a3b1458dcb5eb08cffdc4","impliedFormat":1},{"version":"ada76d272991b9fa901b2fbd538f748a9294f7b9b4bc2764c03c0c9723739fd1","impliedFormat":1},{"version":"6409389a0fa9db5334e8fbcb1046f0a1f9775abce0da901a5bc4fec1e458917c","impliedFormat":1},{"version":"af8d9efb2a64e68ac4c224724ac213dbc559bcfc165ce545d498b1c2d5b2d161","impliedFormat":1},{"version":"094faf910367cc178228cafe86f5c2bd94a99446f51e38d9c2a4eb4c0dec534d","impliedFormat":1},{"version":"dc4cf53cebe96ef6b569db81e9572f55490bd8a0e4f860aac02b7a0e45292c71","impliedFormat":1},{"version":"2c23e2a6219fbce2801b2689a9920548673d7ca0e53859200d55a0d5d05ea599","impliedFormat":1},{"version":"62491ce05a8e3508c8f7366208287c5fded66aad2ba81854aa65067d328281cc","impliedFormat":1},{"version":"8be1b9d5a186383e435c71d371e85016f92aa25e7a6a91f29aa7fd47651abf55","impliedFormat":1},{"version":"95a1b43dfa67963bd60eb50a556e3b08a9aea65a9ffa45504e5d92d34f58087a","impliedFormat":1},{"version":"b872dcd2b627694001616ab82e6aaec5a970de72512173201aae23f7e3f6503d","impliedFormat":1},{"version":"13517c2e04de0bbf4b33ff0dde160b0281ee47d1bf8690f7836ba99adc56294b","impliedFormat":1},{"version":"a9babac4cb35b319253dfc0f48097bcb9e7897f4f5762a5b1e883c425332d010","impliedFormat":1},{"version":"3d97a5744e12e54d735e7755eabc719f88f9d651e936ff532d56bdd038889fc4","impliedFormat":1},{"version":"7fffc8f7842b7c4df1ae19df7cc18cd4b1447780117fca5f014e6eb9b1a7215e","impliedFormat":1},{"version":"aaea91db3f0d14aca3d8b57c5ffb40e8d6d7232e65947ca6c00ae0c82f0a45dc","impliedFormat":1},{"version":"c62eefdcc2e2266350340ffaa43c249d447890617b037205ac6bb45bb7f5a170","impliedFormat":1},{"version":"9924ad46287d634cf4454fdbbccd03e0b7cd2e0112b95397c70d859ae00a5062","impliedFormat":1},{"version":"b940719c852fd3d759e123b29ace8bbd2ec9c5e4933c10749b13426b096a96a1","impliedFormat":1},{"version":"2745055e3218662533fbaddfb8e2e3186f50babe9fb09e697e73de5340c2ad40","impliedFormat":1},{"version":"5d6b6e6a7626621372d2d3bbe9e66b8168dcd5a40f93ae36ee339a68272a0d8b","impliedFormat":1},{"version":"64868d7db2d9a4fde65524147730a0cccdbd1911ada98d04d69f865ea93723d8","impliedFormat":1},{"version":"368b06a0dd2a29a35794eaa02c2823269a418761d38fdb5e1ac0ad2d7fdd0166","impliedFormat":1},{"version":"20164fb31ecfad1a980bd183405c389149a32e1106993d8224aaa93aae5bfbb9","impliedFormat":1},{"version":"bb4b51c75ee079268a127b19bf386eb979ab370ce9853c7d94c0aca9b75aff26","impliedFormat":1},{"version":"f0ef6f1a7e7de521846c163161b0ec7e52ce6c2665a4e0924e1be73e5e103ed3","impliedFormat":1},{"version":"84ab3c956ae925b57e098e33bd6648c30cdab7eca38f5e5b3512d46f6462b348","impliedFormat":1},{"version":"70d6692d0723d6a8b2c6853ed9ab6baaa277362bb861cf049cb12529bd04f68e","impliedFormat":1},{"version":"b35dc79960a69cd311a7c1da15ee30a8ab966e6db26ec99c2cc339b93b028ff6","impliedFormat":1},{"version":"29d571c13d8daae4a1a41d269ec09b9d17b2e06e95efd6d6dc2eeb4ff3a8c2ef","impliedFormat":1},{"version":"5f8a5619e6ae3fb52aaaa727b305c9b8cbe5ff91fa1509ffa61e32f804b55bd8","impliedFormat":1},{"version":"15becc25682fa4c93d45d92eab97bc5d1bb0563b8c075d98f4156e91652eec86","impliedFormat":1},{"version":"702f5c10b38e8c223e1d055d3e6a3f8c572aa421969c5d8699220fbc4f664901","impliedFormat":1},{"version":"4db15f744ba0cd3ae6b8ac9f6d043bf73d8300c10bbe4d489b86496e3eb1870b","impliedFormat":1},{"version":"80841050a3081b1803dbee94ff18c8b1770d1d629b0b6ebaf3b0351a8f42790b","impliedFormat":1},{"version":"9b7987f332830a7e99a4a067e34d082d992073a4dcf26acd3ecf41ca7b538ed5","impliedFormat":1},{"version":"e95b8e0dc325174c9cb961a5e38eccfe2ac15f979b202b0e40fa7e699751b4e9","impliedFormat":1},{"version":"21360a9fd6895e97cbbd36b7ce74202548710c8e833a36a2f48133b3341c2e8f","impliedFormat":1},{"version":"d74ac436397aa26367b37aa24bdae7c1933d2fed4108ff93c9620383a7f65855","impliedFormat":1},{"version":"65825f8fda7104efe682278afec0a63aeb3c95584781845c58d040d537d3cfed","impliedFormat":1},{"version":"1f467a5e086701edf716e93064f672536fc084bba6fc44c3de7c6ae41b91ac77","impliedFormat":1},{"version":"7e12b5758df0e645592f8252284bfb18d04f0c93e6a2bf7a8663974c88ef01de","impliedFormat":1},{"version":"47dbc4b0afb6bc4c131b086f2a75e35cbae88fb68991df2075ca0feb67bbe45b","impliedFormat":1},{"version":"146d8745ed5d4c6028d9a9be2ecf857da6c241bbbf031976a3dc9b0e17efc8a1","impliedFormat":1},{"version":"c4be9442e9de9ee24a506128453cba1bdf2217dbc88d86ed33baf2c4cbfc3e84","impliedFormat":1},{"version":"c9b42fef8c9d035e9ee3be41b99aae7b1bc1a853a04ec206bf0b3134f4491ec8","impliedFormat":1},{"version":"e6a958ab1e50a3bda4857734954cd122872e6deea7930d720afeebd9058dbaa5","impliedFormat":1},{"version":"088adb4a27dab77e99484a4a5d381f09420b9d7466fce775d9fbd3c931e3e773","impliedFormat":1},{"version":"ddf3d7751343800454d755371aa580f4c5065b21c38a716502a91fbb6f0ef92b","impliedFormat":1},{"version":"9b93adcccd155b01b56b55049028baac649d9917379c9c50c0291d316c6b9cdd","impliedFormat":1},{"version":"b48c56cc948cdf5bc711c3250a7ccbdd41f24f5bbbca8784de4c46f15b3a1e27","impliedFormat":1},{"version":"9eeee88a8f1eed92c11aea07551456a0b450da36711c742668cf0495ffb9149c","impliedFormat":1},{"version":"aeb081443dadcb4a66573dba7c772511e6c3f11c8fa8d734d6b0739e5048eb37","impliedFormat":1},{"version":"acf16021a0b863117ff497c2be4135f3c2d6528e4166582d306c4acb306cb639","impliedFormat":1},{"version":"13fbdad6e115524e50af76b560999459b3afd2810c1cbaa52c08cdc1286d2564","impliedFormat":1},{"version":"d3972149b50cdea8e6631a9b4429a5a9983c6f2453070fb8298a5d685911dc46","impliedFormat":1},{"version":"e2dcfcb61b582c2e1fa1a83e3639e2cc295c79be4c8fcbcbeef9233a50b71f7b","impliedFormat":1},{"version":"4e49b8864a54c0dcde72d637ca1c5718f5c017f378f8c9024eff5738cd84738f","impliedFormat":1},{"version":"8db9eaf81db0fc93f4329f79dd05ea6de5654cabf6526adb0b473d6d1cd1f331","impliedFormat":1},{"version":"f76d2001e2c456b814761f2057874dd775e2f661646a5b4bacdcc4cdaf00c3e6","impliedFormat":1},{"version":"d95afdd2f35228db20ec312cb7a014454c80e53a8726906bd222a9ad56f58297","impliedFormat":1},{"version":"8302bf7d5a3cb0dc5c943f77c43748a683f174fa5fae95ad87c004bf128950ce","impliedFormat":1},{"version":"ced33b4c97c0c078254a2a2c1b223a68a79157d1707957d18b0b04f7450d1ad5","impliedFormat":1},{"version":"0e31e4ec65a4d12b088ecf5213c4660cb7d37181b4e7f1f2b99fe58b1ba93956","impliedFormat":1},{"version":"3028552149f473c2dcf073c9e463d18722a9b179a70403edf8b588fcea88f615","impliedFormat":1},{"version":"0ccbcaa5cb885ad2981e4d56ed6845d65e8d59aba9036796c476ca152bc2ee37","impliedFormat":1},{"version":"cb86555aef01e7aa1602fce619da6de970bb63f84f8cffc4d21a12e60cd33a8c","impliedFormat":1},{"version":"a23c3bb0aecfbb593df6b8cb4ba3f0d5fc1bf93c48cc068944f4c1bdb940cb11","impliedFormat":1},{"version":"544c1aa6fcc2166e7b627581fdd9795fc844fa66a568bfa3a1bc600207d74472","impliedFormat":1},{"version":"745c7e4f6e3666df51143ed05a1200032f57d71a180652b3528c5859a062e083","impliedFormat":1},{"version":"0308b7494aa630c6ecc0e4f848f85fcad5b5d6ef811d5c04673b78cf3f87041c","impliedFormat":1},{"version":"c540aea897a749517aea1c08aeb2562b8b6fc9e70f938f55b50624602cc8b2e4","impliedFormat":1},{"version":"a1ab0c6b4400a900efd4cd97d834a72b7aeaa4b146a165043e718335f23f9a5f","impliedFormat":1},{"version":"89ebe83d44d78b6585dfd547b898a2a36759bc815c87afdf7256204ab453bd08","impliedFormat":1},{"version":"e6a29b3b1ac19c5cdf422685ac0892908eb19993c65057ec4fd3405ebf62f03d","impliedFormat":1},{"version":"c43912d69f1d4e949b0b1ce3156ad7bc169589c11f23db7e9b010248fdd384fa","impliedFormat":1},{"version":"d585b623240793e85c71b537b8326b5506ec4e0dcbb88c95b39c2a308f0e81ba","impliedFormat":1},{"version":"aac094f538d04801ebf7ea02d4e1d6a6b91932dbce4894acb3b8d023fdaa1304","impliedFormat":1},{"version":"da0d796387b08a117070c20ec46cc1c6f93584b47f43f69503581d4d95da2a1e","impliedFormat":1},{"version":"f2307295b088c3da1afb0e5a390b313d0d9b7ff94c7ba3107b2cdaf6fca9f9e6","impliedFormat":1},{"version":"d00bd133e0907b71464cbb0adae6353ebbec6977671d34d3266d75f11b9591a8","impliedFormat":1},{"version":"c3616c3b6a33defc62d98f1339468f6066842a811c6f7419e1ee9cae9db39184","impliedFormat":1},{"version":"7d068fc64450fc5080da3772705441a48016e1022d15d1d738defa50cac446b8","impliedFormat":1},{"version":"4c3c31fba20394c26a8cfc2a0554ae3d7c9ba9a1bc5365ee6a268669851cfe19","impliedFormat":1},{"version":"584e168e0939271bcec62393e2faa74cff7a2f58341c356b3792157be90ea0f7","impliedFormat":1},{"version":"50b6829d9ef8cf6954e0adf0456720dd3fd16f01620105072bae6be3963054d1","impliedFormat":1},{"version":"a72a2dd0145eaf64aa537c22af8a25972c0acf9db1a7187fa00e46df240e4bb0","impliedFormat":1},{"version":"0008a9f24fcd300259f8a8cd31af280663554b67bf0a60e1f481294615e4c6aa","impliedFormat":1},{"version":"21738ef7b3baf3065f0f186623f8af2d695009856a51e1d2edf9873cee60fe3a","impliedFormat":1},{"version":"19c9f153e001fb7ab760e0e3a5df96fa8b7890fc13fc848c3b759453e3965bf0","impliedFormat":1},{"version":"5d3a82cef667a1cff179a0a72465a34a6f1e31d3cdba3adce27b70b85d69b071","impliedFormat":1},{"version":"38763534c4b9928cd33e7d1c2141bc16a8d6719e856bf88fda57ef2308939d82","impliedFormat":1},{"version":"292ec7e47dfc1f6539308adc8a406badff6aa98c246f57616b5fa412d58067f8","impliedFormat":1},{"version":"a11ee86b5bc726da1a2de014b71873b613699cfab8247d26a09e027dee35e438","impliedFormat":1},{"version":"95a595935eecbce6cc8615c20fafc9a2d94cf5407a5b7ff9fa69850bbef57169","impliedFormat":1},{"version":"c42fc2b9cf0b6923a473d9c85170f1e22aa098a2c95761f552ec0b9e0a620d69","impliedFormat":1},{"version":"8c9a55357196961a07563ac00bb6434c380b0b1be85d70921cd110b5e6db832d","impliedFormat":1},{"version":"73149a58ebc75929db972ab9940d4d0069d25714e369b1bc6e33bc63f1f8f094","impliedFormat":1},{"version":"c98f5a640ffecf1848baf321429964c9db6c2e943c0a07e32e8215921b6c36c3","impliedFormat":1},{"version":"43738308660af5cb4a34985a2bd18e5e2ded1b2c8f8b9c148fca208c5d2768a6","impliedFormat":1},{"version":"bb4fa3df2764387395f30de00e17d484a51b679b315d4c22316d2d0cd76095d6","impliedFormat":1},{"version":"0498a3d27ec7107ba49ecc951e38c7726af555f438bab1267385677c6918d8ec","impliedFormat":1},{"version":"fe24f95741e98d4903772dc308156562ae7e4da4f3845e27a10fab9017edae75","impliedFormat":1},{"version":"b63482acb91346b325c20087e1f2533dc620350bf7d0aa0c52967d3d79549523","impliedFormat":1},{"version":"2aef798b8572df98418a7ac4259b315df06839b968e2042f2b53434ee1dc2da4","impliedFormat":1},{"version":"249c41965bd0c7c5b987f242ac9948a2564ef92d39dde6af1c4d032b368738b0","impliedFormat":1},{"version":"7141b7ffd1dcd8575c4b8e30e465dd28e5ae4130ff9abd1a8f27c68245388039","impliedFormat":1},{"version":"d1dd80825d527d2729f4581b7da45478cdaaa0c71e377fd2684fb477761ea480","impliedFormat":1},{"version":"e78b1ba3e800a558899aba1a50704553cf9dc148036952f0b5c66d30b599776d","impliedFormat":1},{"version":"be4ccea4deb9339ca73a5e6a8331f644a6b8a77d857d21728e911eb3271a963c","impliedFormat":1},{"version":"3ee5a61ffc7b633157279afd7b3bd70daa989c8172b469d358aed96f81a078ef","impliedFormat":1},{"version":"23c63869293ca315c9e8eb9359752704068cc5fff98419e49058838125d59b1e","impliedFormat":1},{"version":"af0a68781958ab1c73d87e610953bd70c062ddb2ab761491f3e125eadef2a256","impliedFormat":1},{"version":"c20c624f1b803a54c5c12fdd065ae0f1677f04ffd1a21b94dddee50f2e23f8ec","impliedFormat":1},{"version":"49ef6d2d93b793cc3365a79f31729c0dc7fc2e789425b416b1a4a5654edb41ac","impliedFormat":1},{"version":"c2151736e5df2bdc8b38656b2e59a4bb0d7717f7da08b0ae9f5ddd1e429d90a1","impliedFormat":1},{"version":"3f1baacc3fc5e125f260c89c1d2a940cdccb65d6adef97c9936a3ac34701d414","impliedFormat":1},{"version":"3603cbabe151a2bea84325ce1ea57ca8e89f9eb96546818834d18fb7be5d4232","impliedFormat":1},{"version":"989762adfa2de753042a15514f5ccc4ed799b88bdc6ac562648972b26bc5bc60","impliedFormat":1},{"version":"a23f251635f89a1cc7363cae91e578073132dc5b65f6956967069b2b425a646a","impliedFormat":1},{"version":"995ed46b1839b3fc9b9a0bd5e7572120eac3ba959fa8f5a633be9bcded1f87ae","impliedFormat":1},{"version":"ddabaf119da03258aa0a33128401bbb91c54ef483e9de0f87be1243dd3565144","impliedFormat":1},{"version":"4e79855295a233d75415685fa4e8f686a380763e78a472e3c6c52551c6b74fd3","impliedFormat":1},{"version":"3b036f77ed5cbb981e433f886a07ec719cf51dd6c513ef31e32fd095c9720028","impliedFormat":1},{"version":"ee58f8fca40561d30c9b5e195f39dbc9305a6f2c8e1ff2bf53204cacb2cb15c0","impliedFormat":1},{"version":"83ac7ceab438470b6ddeffce2c13d3cf7d22f4b293d1e6cdf8f322edcd87a393","impliedFormat":1},{"version":"ef0e7387c15b5864b04dd9358513832d1c93b15f4f07c5226321f5f17993a0e2","impliedFormat":1},{"version":"86b6a71515872d5286fbcc408695c57176f0f7e941c8638bcd608b3718a1e28c","impliedFormat":1},{"version":"be59c70c4576ea08eee55cf1083e9d1f9891912ef0b555835b411bc4488464d4","impliedFormat":1},{"version":"57c97195e8efcfc808c41c1b73787b85588974181349b6074375eb19cc3bba91","impliedFormat":1},{"version":"d7cafcc0d3147486b39ac4ad02d879559dd3aa8ac4d0600a0c5db66ab621bdf3","impliedFormat":1},{"version":"b5c8e50e4b06f504513ca8c379f2decb459d9b8185bdcd1ee88d3f7e69725d3b","impliedFormat":1},{"version":"122621159b4443b4e14a955cf5f1a23411e6a59d2124d9f0d59f3465eddc97ec","impliedFormat":1},{"version":"c4889859626d56785246179388e5f2332c89fa4972de680b9b810ab89a9502cd","impliedFormat":1},{"version":"e9395973e2a57933fcf27b0e95b72cb45df8ecc720929ce039fc1c9013c5c0dc","impliedFormat":1},{"version":"a81723e440f533b0678ce5a3e7f5046a6bb514e086e712f9be98ebef74bd39b8","impliedFormat":1},{"version":"298d10f0561c6d3eb40f30001d7a2c8a5aa1e1e7e5d1babafb0af51cc27d2c81","impliedFormat":1},{"version":"e256d96239faffddf27f67ff61ab186ad3adaa7d925eeaf20ba084d90af1df19","impliedFormat":1},{"version":"8357843758edd0a0bd1ef4283fcabb50916663cf64a6a0675bd0996ae5204f3d","impliedFormat":1},{"version":"1525d7dd58aad8573ae1305cc30607d35c9164a8e2b0b14c7d2eaea44143f44b","impliedFormat":1},{"version":"fd19dff6b77e377451a1beacb74f0becfee4e7f4c2906d723570f6e7382bd46f","impliedFormat":1},{"version":"3f3ef670792214404589b74e790e7347e4e4478249ca09db51dc8a7fca6c1990","impliedFormat":1},{"version":"0da423d17493690db0f1adc8bf69065511c22dd99c478d9a2b59df704f77301b","impliedFormat":1},{"version":"ba627cd6215902dbe012e96f33bd4bf9ad0eefc6b14611789c52568cf679dc07","impliedFormat":1},{"version":"5fce817227cd56cb5642263709b441f118e19a64af6b0ed520f19fa032bdb49e","impliedFormat":1},{"version":"754107d580b33acc15edffaa6ac63d3cdf40fb11b1b728a2023105ca31fcb1a8","impliedFormat":1},{"version":"03cbeabd581d540021829397436423086e09081d41e3387c7f50df8c92d93b35","impliedFormat":1},{"version":"91322bf698c0c547383d3d1a368e5f1f001d50b9c3c177de84ab488ead82a1b8","impliedFormat":1},{"version":"79337611e64395512cad3eb04c8b9f50a2b803fa0ae17f8614f19c1e4a7eef8d","impliedFormat":1},{"version":"6835fc8e288c1a4c7168a72a33cb8a162f5f52d8e1c64e7683fc94f427335934","impliedFormat":1},{"version":"a90a83f007a1dece225eb2fd59b41a16e65587270bd405a2eb5f45aa3d2b2044","impliedFormat":1},{"version":"320333b36a5e801c0e6cee69fb6edc2bcc9d192cd71ee1d28c4b46467c69d0b4","impliedFormat":1},{"version":"e4e2457e74c4dc9e0bb7483113a6ba18b91defc39d6a84e64b532ad8a4c9951c","impliedFormat":1},{"version":"c39fb1745e021b123b512b86c41a96497bf60e3c8152b167da11836a6e418fd7","impliedFormat":1},{"version":"95ab9fb3b863c4f05999f131c0d2bd44a9de8e7a36bb18be890362aafa9f0a26","impliedFormat":1},{"version":"c95da8d445b765b3f704c264370ac3c92450cefd9ec5033a12f2b4e0fca3f0f4","impliedFormat":1},{"version":"ac534eb4f4c86e7bef6ed3412e7f072ec83fe36a73e79cbf8f3acb623a2447bb","impliedFormat":1},{"version":"a2a295f55159b84ca69eb642b99e06deb33263b4253c32b4119ea01e4e06a681","impliedFormat":1},{"version":"271584dd56ae5c033542a2788411e62a53075708f51ee4229c7f4f7804b46f98","impliedFormat":1},{"version":"f8fe7bba5c4b19c5e84c614ffcd3a76243049898678208f7af0d0a9752f17429","impliedFormat":1},{"version":"bad7d161bfe5943cb98c90ec486a46bf2ebc539bd3b9dbc3976968246d8c801d","impliedFormat":1},{"version":"be1f9104fa3890f1379e88fdbb9e104e5447ac85887ce5c124df4e3b3bc3fece","impliedFormat":1},{"version":"2d38259c049a6e5f2ea960ff4ad0b2fb1f8d303535afb9d0e590bb4482b26861","impliedFormat":1},{"version":"ae07140e803da03cc30c595a32bb098e790423629ab94fdb211a22c37171af5a","impliedFormat":1},{"version":"b0b6206f9b779be692beab655c1e99ec016d62c9ea6982c7c0108716d3ebb2ec","impliedFormat":1},{"version":"cc39605bf23068cbec34169b69ef3eb1c0585311247ceedf7a2029cf9d9711bd","impliedFormat":1},{"version":"132d600b779fb52dba5873aadc1e7cf491996c9e5abe50bcbc34f5e82c7bfe8a","impliedFormat":1},{"version":"429a4b07e9b7ff8090cc67db4c5d7d7e0a9ee5b9e5cd4c293fd80fca84238f14","impliedFormat":1},{"version":"4ffb10b4813cdca45715d9a8fc8f54c4610def1820fae0e4e80a469056e3c3d5","impliedFormat":1},{"version":"673a5aa23532b1d47a324a6945e73a3e20a6ec32c7599e0a55b2374afd1b098d","impliedFormat":1},{"version":"a70d616684949fdff06a57c7006950592a897413b2d76ec930606c284f89e0b9","impliedFormat":1},{"version":"ddfff10877e34d7c341cb85e4e9752679f9d1dd03e4c20bf2a8d175eda58d05b","impliedFormat":1},{"version":"d4afbe82fbc4e92c18f6c6e4007c68e4971aca82b887249fdcb292b6ae376153","impliedFormat":1},{"version":"9a6a791ca7ed8eaa9a3953cbf58ec5a4211e55c90dcd48301c010590a68b945e","impliedFormat":1},{"version":"10098d13345d8014bbfd83a3f610989946b3c22cdec1e6b1af60693ab6c9f575","impliedFormat":1},{"version":"0b5880de43560e2c042c5337f376b1a0bdae07b764a4e7f252f5f9767ebad590","impliedFormat":1},{"version":"2879a055439b6c0c0132a1467120a0f85b56b5d735c973ad235acd958b1b5345","impliedFormat":1},{"version":"a80b7bc4eda856374c26a56f6f25297f4c393309d4c4548002a5238cd57b2b66","impliedFormat":1},"3c1fac4f78df1041c0ecd16f6fc7f9512251b711e12943e3411f6624cede2bdd",{"version":"8dd450de6d756cee0761f277c6dc58b0b5a66b8c274b980949318b8cad26d712","impliedFormat":1},{"version":"904d6ad970b6bd825449480488a73d9b98432357ab38cf8d31ffd651ae376ff5","impliedFormat":1},{"version":"0eca9db21fa3ff4874640e1bec31c03da0615462388c07e7299e1b930851a80c","impliedFormat":1},"3a5ed441b9ddac9936f8cb052299142de5e2269c9a2c7aba39996d71ba2ec85a",{"version":"caf4af98bf464ad3e10c46cf7d340556f89197aab0f87f032c7b84eb8ddb24d9","impliedFormat":1},{"version":"71acd198e19fa38447a3cbc5c33f2f5a719d933fccf314aaff0e8b0593271324","impliedFormat":1},"b50ce854ad22d6df9dee3e2851083687fb07d10c002a2364608446278014a968","adefbba984a87c0f3059432a049ca2ef7e46bd93adeab0ee1828e3886b8cabb3","927de568a3cf481bf0040ced7703bbe24deb44952e838940dc7ecbe789711232",{"version":"41baad0050b9280cfe30362c267eba7b89161d528112bccea69f7b4d49ab3102","impliedFormat":1},"53b069e0b6ddfbd990397c6889801e4c83d430adc88d6d08cababfa522ff18a4",{"version":"6b5f886fe41e2e767168e491fe6048398ed6439d44e006d9f51cc31265f08978","impliedFormat":1},{"version":"f4a1eba860f7493d19df42373ddde4f3c6f31aa574b608e55e5b2bd459bba587","impliedFormat":1},{"version":"6388a549ff1e6a2d5d18da48709bb167ea28062b573ff1817016099bc6138861","impliedFormat":1},{"version":"32d280360f1bcc8b4721ff72d11a29a79ac2cb0e82dde14eea891bf73ba98f9d","impliedFormat":1},"4fed67addadd0cb81d5fd95ace0b43f4a9639ff425ec594042a0be704d98be00","dcae0b8e192da5304304681a3cc594a940b85b2249be8c45c1f71d61e7211090","734fc419cc0723016aac35e9d433ad405207cb1a29668ea91e7ead041874d00c","62d3ce5b8f548990a8f188247f539defc3075b2793d673beca132c5abc560b6f","765efb51f937b2f1e73601646da5407ff54a4a86adb0fad487910682357ab26d",{"version":"9e4b070b543d91d0b321a481e1119e99bb8f136f4ef271d7b5ba264919fc32e2","impliedFormat":1},{"version":"5f877dfc985d1fd3ac8bf4a75cd77b06c42ca608809b324c44b4151758de7189","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9a60e36a4cc38129e1882b28e24bd1d47f2bf62e7708d611384b223f31ad20b","affectsGlobalScope":true,"impliedFormat":1},{"version":"14c2fd6220654a41c53836a62ba96d4b515ae1413b0ccb31c2445fb1ae1de5de","affectsGlobalScope":true,"impliedFormat":1},{"version":"4f29c38739500cd35a2ce41d15a35e34445ca755ebb991915b5f170985a49d21","affectsGlobalScope":true,"impliedFormat":1},{"version":"d3842a6977bc70be229c3397123adaa686d99e161c9927ae85b6f6890be401e7","affectsGlobalScope":true,"impliedFormat":1},{"version":"efc7d584a33fe3422847783d228f315c4cd1afe74bd7cf8e3f0e4c1125129fef","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"4967529644e391115ca5592184d4b63980569adf60ee685f968fd59ab1557188","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"7180c03fd3cb6e22f911ce9ba0f8a7008b1a6ddbe88ccf16a9c8140ef9ac1686","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"54cb85a47d760da1c13c00add10d26b5118280d44d58e6908d8e89abbd9d7725","impliedFormat":1},{"version":"3e4825171442666d31c845aeb47fcd34b62e14041bb353ae2b874285d78482aa","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"a967bfe3ad4e62243eb604bf956101e4c740f5921277c60debaf325c1320bf88","impliedFormat":1},{"version":"e9775e97ac4877aebf963a0289c81abe76d1ec9a2a7778dbe637e5151f25c5f3","impliedFormat":1},{"version":"471e1da5a78350bc55ef8cef24eb3aca6174143c281b8b214ca2beda51f5e04a","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"db3435f3525cd785bf21ec6769bf8da7e8a776be1a99e2e7efb5f244a2ef5fee","impliedFormat":1},{"version":"c3b170c45fc031db31f782e612adf7314b167e60439d304b49e704010e7bafe5","impliedFormat":1},{"version":"40383ebef22b943d503c6ce2cb2e060282936b952a01bea5f9f493d5fb487cc7","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"3a84b7cb891141824bd00ef8a50b6a44596aded4075da937f180c90e362fe5f6","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"33203609eba548914dc83ddf6cadbc0bcb6e8ef89f6d648ca0908ae887f9fcc5","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"e53a3c2a9f624d90f24bf4588aacd223e7bec1b9d0d479b68d2f4a9e6011147f","impliedFormat":1},{"version":"339dc5265ee5ed92e536a93a04c4ebbc2128f45eeec6ed29f379e0085283542c","impliedFormat":1},{"version":"9f0a92164925aa37d4a5d9dd3e0134cff8177208dba55fd2310cd74beea40ee2","impliedFormat":1},{"version":"8bfdb79bf1a9d435ec48d9372dc93291161f152c0865b81fc0b2694aedb4578d","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"d32275be3546f252e3ad33976caf8c5e842c09cb87d468cb40d5f4cf092d1acc","impliedFormat":1},{"version":"4a0c3504813a3289f7fb1115db13967c8e004aa8e4f8a9021b95285502221bd1","impliedFormat":1},{"version":"a14ed46fa3f5ffc7a8336b497cd07b45c2084213aaca933a22443fcb2eef0d07","affectsGlobalScope":true,"impliedFormat":1},{"version":"cce1f5f86974c1e916ec4a8cab6eec9aa8e31e8148845bf07fbaa8e1d97b1a2c","impliedFormat":1},{"version":"e2eb1ce13a9c0fa7ab62c63909d81973ef4b707292667c64f1e25e6e53fa7afa","affectsGlobalScope":true,"impliedFormat":1},{"version":"16d74fe4d8e183344d3beb15d48b123c5980ff32ff0cc8c3b96614ddcdf9b239","impliedFormat":1},{"version":"7b43160a49cf2c6082da0465876c4a0b164e160b81187caeb0a6ca7a281e85ba","impliedFormat":1},{"version":"41fb2a1c108fbf46609ce5a451b7ec78eb9b5ada95fd5b94643e4b26397de0b3","affectsGlobalScope":true,"impliedFormat":1},{"version":"a40826e8476694e90da94aa008283a7de50d1dafd37beada623863f1901cb7fb","impliedFormat":1},{"version":"1b282e90846fada1e96dc1cf5111647d6ab5985c8d7b5c542642f1ea2739406d","impliedFormat":1},{"version":"bd3f5d05b6b5e4bfcea7739a45f3ffb4a7f4a3442ba7baf93e0200799285b8f1","impliedFormat":1},{"version":"4c775c2fccabf49483c03cd5e3673f87c1ffb6079d98e7b81089c3def79e29c6","impliedFormat":1},{"version":"8806ae97308ef26363bd7ec8071bca4d07fb575f905ee3d8a91aff226df6d618","impliedFormat":1},{"version":"af5bf1db6f1804fb0069039ae77a05d60133c77a2158d9635ea27b6bb2828a8f","impliedFormat":1},{"version":"b7fe70be794e13d1b7940e318b8770cd1fb3eced7707805318a2e3aaac2c3e9e","impliedFormat":1},{"version":"2c71199d1fc83bf17636ad5bf63a945633406b7b94887612bba4ef027c662b3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"7ae9dc7dbb58cd843065639707815df85c044babaa0947116f97bdb824d07204","affectsGlobalScope":true,"impliedFormat":1},{"version":"7aae1df2053572c2cfc2089a77847aadbb38eedbaa837a846c6a49fb37c6e5bd","impliedFormat":1},{"version":"313a0b063f5188037db113509de1b934a0e286f14e9479af24fada241435e707","impliedFormat":1},{"version":"f1ace2d2f98429e007d017c7a445efad2aaebf8233135abdb2c88b8c0fef91ab","impliedFormat":1},{"version":"87ef1a23caa071b07157c72077fa42b86d30568f9dc9e31eed24d5d14fc30ba8","impliedFormat":1},{"version":"396a8939b5e177542bdf9b5262b4eee85d29851b2d57681fa9d7eae30e225830","impliedFormat":1},{"version":"21773f5ac69ddf5a05636ba1f50b5239f4f2d27e4420db147fc2f76a5ae598ac","impliedFormat":1},{"version":"ea455cc68871b049bcecd9f56d4cf27b852d6dafd5e3b54468ca87cc11604e4d","affectsGlobalScope":true,"impliedFormat":1},{"version":"a5fe4cc622c3bf8e09ababde5f4096ceac53163eefcd95e9cd53f062ff9bb67a","impliedFormat":1},{"version":"45b1053e691c5af9bfe85060a3e1542835f8d84a7e6e2e77ca305251eda0cb3c","impliedFormat":1},{"version":"0f05c06ff6196958d76b865ae17245b52d8fe01773626ac3c43214a2458ea7b7","impliedFormat":1},{"version":"ae5507fc333d637dec9f37c6b3f4d423105421ea2820a64818de55db85214d66","affectsGlobalScope":true,"impliedFormat":1},{"version":"0666f4c99b8688c7be5956df8fecf5d1779d3b22f8f2a88258ae7072c7b6026f","affectsGlobalScope":true,"impliedFormat":1},{"version":"8abd0566d2854c4bd1c5e48e05df5c74927187f1541e6770001d9637ac41542e","impliedFormat":1},{"version":"54e854615c4eafbdd3fd7688bd02a3aafd0ccf0e87c98f79d3e9109f047ce6b8","impliedFormat":1},{"version":"d8dba11dc34d50cb4202de5effa9a1b296d7a2f4a029eec871f894bddfb6430d","impliedFormat":1},{"version":"8b71dd18e7e63b6f991b511a201fad7c3bf8d1e0dd98acb5e3d844f335a73634","impliedFormat":1},{"version":"01d8e1419c84affad359cc240b2b551fb9812b450b4d3d456b64cda8102d4f60","impliedFormat":1},{"version":"8221b00f271cf7f535a8eeec03b0f80f0929c7a16116e2d2df089b41066de69b","impliedFormat":1},{"version":"269929a24b2816343a178008ac9ae9248304d92a8ba8e233055e0ed6dbe6ef71","impliedFormat":1},{"version":"93452d394fdd1dc551ec62f5042366f011a00d342d36d50793b3529bfc9bd633","impliedFormat":1},{"version":"7424817d5eb498771e6d1808d726ec38f75d2eaf3fa359edd5c0c540c52725c1","impliedFormat":1},{"version":"831c22d257717bf2cbb03afe9c4bcffc5ccb8a2074344d4238bf16d3a857bb12","impliedFormat":1},{"version":"bddce945d552a963c9733db106b17a25474eefcab7fc990157a2134ef55d4954","affectsGlobalScope":true,"impliedFormat":1},{"version":"7052b7b0c3829df3b4985bab2fd74531074b4835d5a7b263b75c82f0916ad62f","affectsGlobalScope":true,"impliedFormat":1},{"version":"aa34c3aa493d1c699601027c441b9664547c3024f9dbab1639df7701d63d18fa","impliedFormat":1},{"version":"eefcdf86cefff36e5d87de36a3638ab5f7d16c2b68932be4a72c14bb924e43c1","impliedFormat":1},{"version":"7c651f8dce91a927ab62925e73f190763574c46098f2b11fb8ddc1b147a6709a","impliedFormat":1},{"version":"7440ab60f4cb031812940cc38166b8bb6fbf2540cfe599f87c41c08011f0c1df","impliedFormat":1},{"version":"4d0405568cf6e0ff36a4861c4a77e641366feaefa751600b0a4d12a5e8f730a8","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5b5dc128973498b75f52b1b8c2d5f8629869104899733ae485100c2309b4c12","affectsGlobalScope":true,"impliedFormat":1},{"version":"e393915d3dc385e69c0e2390739c87b2d296a610662eb0b1cb85224e55992250","impliedFormat":1},{"version":"79bad8541d5779c85e82a9fb119c1fe06af77a71cc40f869d62ad379473d4b75","impliedFormat":1},{"version":"8013f6c4d1632da8f1c4d3d702ae559acccd0f1be05360c31755f272587199c9","impliedFormat":1},{"version":"629d20681ca284d9e38c0a019f647108f5fe02f9c59ac164d56f5694fc3faf4d","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7dbf5716d76846c7522e910896c5747b6df1abd538fee8f5291bdc843461795","impliedFormat":1},{"version":"ab9b9a36e5284fd8d3bf2f7d5fcbc60052f25f27e4d20954782099282c60d23e","affectsGlobalScope":true,"impliedFormat":1},{"version":"b510d0a18e3db42ac9765d26711083ec1e8b4e21caaca6dc4d25ae6e8623f447","impliedFormat":1},{"version":"7ac7ef12f7ece6464d83d2d56fea727260fb954fdd51a967e94f97b8595b714b","impliedFormat":1}],"root":[87,90,[92,94],97,98,105,108,[126,128],451,455,[458,460],462,[467,471]],"options":{"allowJs":true,"declaration":false,"declarationMap":false,"esModuleInterop":true,"inlineSources":false,"jsx":1,"module":99,"noUnusedLocals":false,"noUnusedParameters":false,"skipLibCheck":true,"strict":false,"target":99,"verbatimModuleSyntax":true},"referencedMap":[[94,1],[98,2],[105,3],[90,4],[108,5],[96,6],[463,7],[454,8],[120,7],[452,7],[130,9],[131,9],[132,9],[133,9],[134,9],[135,9],[136,9],[137,9],[138,9],[139,9],[140,9],[141,9],[142,9],[143,9],[144,9],[145,9],[146,9],[147,9],[148,9],[149,9],[150,9],[151,9],[152,9],[153,9],[154,9],[155,9],[156,9],[158,9],[157,9],[159,9],[160,9],[161,9],[162,9],[163,9],[164,9],[165,9],[166,9],[167,9],[168,9],[169,9],[170,9],[171,9],[172,9],[173,9],[174,9],[175,9],[176,9],[177,9],[178,9],[179,9],[180,9],[181,9],[182,9],[183,9],[184,9],[187,9],[186,9],[185,9],[188,9],[189,9],[190,9],[191,9],[193,9],[192,9],[195,9],[194,9],[196,9],[197,9],[198,9],[199,9],[201,9],[200,9],[202,9],[203,9],[204,9],[205,9],[206,9],[207,9],[208,9],[209,9],[210,9],[211,9],[212,9],[213,9],[216,9],[214,9],[215,9],[217,9],[218,9],[219,9],[220,9],[221,9],[222,9],[223,9],[224,9],[225,9],[226,9],[227,9],[228,9],[230,9],[229,9],[231,9],[232,9],[233,9],[234,9],[235,9],[236,9],[238,9],[237,9],[239,9],[240,9],[241,9],[242,9],[243,9],[244,9],[245,9],[246,9],[247,9],[248,9],[249,9],[251,9],[250,9],[252,9],[254,9],[253,9],[255,9],[256,9],[257,9],[258,9],[260,9],[259,9],[261,9],[262,9],[263,9],[264,9],[265,9],[266,9],[267,9],[268,9],[269,9],[270,9],[271,9],[272,9],[273,9],[274,9],[275,9],[276,9],[277,9],[278,9],[279,9],[280,9],[281,9],[282,9],[283,9],[284,9],[285,9],[286,9],[287,9],[288,9],[290,9],[289,9],[291,9],[292,9],[293,9],[294,9],[295,9],[296,9],[448,10],[297,9],[298,9],[299,9],[300,9],[301,9],[302,9],[303,9],[304,9],[305,9],[306,9],[307,9],[308,9],[309,9],[310,9],[311,9],[312,9],[313,9],[314,9],[315,9],[318,9],[316,9],[317,9],[319,9],[320,9],[321,9],[322,9],[323,9],[324,9],[325,9],[326,9],[327,9],[328,9],[330,9],[329,9],[332,9],[333,9],[331,9],[334,9],[335,9],[336,9],[337,9],[338,9],[339,9],[340,9],[341,9],[342,9],[343,9],[344,9],[345,9],[346,9],[347,9],[348,9],[349,9],[350,9],[351,9],[352,9],[353,9],[354,9],[356,9],[355,9],[358,9],[357,9],[359,9],[360,9],[361,9],[362,9],[363,9],[364,9],[365,9],[366,9],[368,9],[367,9],[369,9],[370,9],[371,9],[372,9],[374,9],[373,9],[375,9],[376,9],[377,9],[378,9],[379,9],[380,9],[381,9],[382,9],[383,9],[384,9],[385,9],[386,9],[387,9],[388,9],[389,9],[390,9],[391,9],[392,9],[393,9],[394,9],[395,9],[397,9],[396,9],[398,9],[399,9],[400,9],[401,9],[402,9],[403,9],[404,9],[405,9],[406,9],[407,9],[408,9],[410,9],[411,9],[412,9],[413,9],[414,9],[415,9],[416,9],[409,9],[417,9],[418,9],[419,9],[420,9],[421,9],[422,9],[423,9],[424,9],[425,9],[426,9],[427,9],[428,9],[429,9],[430,9],[431,9],[432,9],[433,9],[129,11],[434,9],[435,9],[436,9],[437,9],[438,9],[439,9],[440,9],[441,9],[442,9],[443,9],[444,9],[445,9],[446,9],[447,9],[457,12],[466,13],[465,14],[453,7],[119,11],[456,11],[450,15],[121,16],[112,17],[111,18],[110,19],[473,20],[477,21],[476,22],[478,23],[479,23],[514,24],[515,25],[516,26],[517,27],[518,28],[519,29],[520,30],[521,31],[522,32],[523,33],[524,33],[526,34],[525,35],[527,36],[528,37],[529,38],[513,39],[530,40],[531,41],[532,42],[564,43],[533,44],[534,45],[535,46],[536,47],[537,48],[538,49],[539,50],[540,51],[541,52],[542,53],[543,53],[544,54],[545,55],[547,56],[546,57],[548,58],[549,59],[550,60],[551,61],[552,62],[553,63],[554,64],[555,65],[556,66],[557,67],[558,68],[559,69],[560,70],[561,71],[562,72],[88,11],[565,11],[86,73],[449,11],[124,74],[123,75],[461,76],[100,77],[101,77],[103,77],[104,78],[125,11],[89,79],[87,11],[117,80],[118,81],[116,82],[114,83],[113,84],[115,83],[496,85],[503,86],[495,85],[510,87],[487,88],[486,89],[509,90],[504,91],[507,92],[489,93],[488,94],[484,95],[483,96],[506,97],[485,98],[490,99],[494,99],[512,100],[511,99],[498,101],[499,102],[501,103],[497,104],[500,105],[505,90],[492,106],[493,107],[502,108],[482,109],[508,110],[471,111],[470,112],[460,113],[469,114],[468,115],[451,116],[462,117],[459,118],[455,119],[458,120],[467,121],[126,122],[128,123],[127,124],[97,125],[93,126]],"affectedFilesPendingEmit":[94,98,105,90,108,471,470,460,469,468,451,462,459,455,458,467,126,128,127,97,93,92],"version":"5.9.3"}
\ No newline at end of file
diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json
index f5915a875..f12b9722c 100644
--- a/surfsense_desktop/package.json
+++ b/surfsense_desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "surfsense-desktop",
"productName": "SurfSense",
- "version": "0.0.30",
+ "version": "0.0.31",
"description": "SurfSense Desktop App",
"main": "dist/main.js",
"scripts": {
diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts
index 436e0e064..308d8515e 100644
--- a/surfsense_desktop/src/ipc/channels.ts
+++ b/surfsense_desktop/src/ipc/channels.ts
@@ -50,8 +50,8 @@ export const IPC_CHANNELS = {
GET_SHORTCUTS: 'shortcuts:get',
SET_SHORTCUTS: 'shortcuts:set',
// Active search space
- GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active',
- SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active',
+ GET_ACTIVE_WORKSPACE: 'workspace:get-active',
+ SET_ACTIVE_WORKSPACE: 'workspace:set-active',
// Launch on system startup
GET_AUTO_LAUNCH: 'auto-launch:get',
SET_AUTO_LAUNCH: 'auto-launch:set',
diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts
index ab4ba0d92..23b88e292 100644
--- a/surfsense_desktop/src/ipc/handlers.ts
+++ b/surfsense_desktop/src/ipc/handlers.ts
@@ -27,7 +27,7 @@ import {
} from '../modules/folder-watcher';
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch';
-import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
+import { getActiveWorkspaceId, setActiveWorkspaceId } from '../modules/active-workspace';
import { reregisterQuickAsk } from '../modules/quick-ask';
import { reregisterGeneralAssist, reregisterScreenshotAssist } from '../modules/tray';
import {
@@ -205,9 +205,9 @@ export function registerIpcHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT,
- async (_event, virtualPath: string, searchSpaceId?: number | null) => {
+ async (_event, virtualPath: string, workspaceId?: number | null) => {
try {
- const result = await readAgentLocalFileText(virtualPath, searchSpaceId);
+ const result = await readAgentLocalFileText(virtualPath, workspaceId);
return { ok: true, path: result.path, content: result.content };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to read local file';
@@ -218,9 +218,9 @@ export function registerIpcHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT,
- async (_event, virtualPath: string, content: string, searchSpaceId?: number | null) => {
+ async (_event, virtualPath: string, content: string, workspaceId?: number | null) => {
try {
- const result = await writeAgentLocalFileText(virtualPath, content, searchSpaceId);
+ const result = await writeAgentLocalFileText(virtualPath, content, workspaceId);
return { ok: true, path: result.path };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to write local file';
@@ -321,10 +321,10 @@ export function registerIpcHandlers(): void {
},
);
- ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId());
+ ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_WORKSPACE, () => getActiveWorkspaceId());
- ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) =>
- setActiveSearchSpaceId(id)
+ ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_WORKSPACE, (_event, id: string) =>
+ setActiveWorkspaceId(id)
);
ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial) => {
@@ -370,12 +370,12 @@ export function registerIpcHandlers(): void {
};
});
- ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, searchSpaceId?: number | null) =>
- getAgentFilesystemSettings(searchSpaceId)
+ ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, workspaceId?: number | null) =>
+ getAgentFilesystemSettings(workspaceId)
);
- ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, searchSpaceId?: number | null) =>
- getAgentFilesystemMounts(searchSpaceId)
+ ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, workspaceId?: number | null) =>
+ getAgentFilesystemMounts(workspaceId)
);
ipcMain.handle(
@@ -384,7 +384,7 @@ export function registerIpcHandlers(): void {
_event,
options: {
rootPath: string;
- searchSpaceId?: number | null;
+ workspaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}
@@ -397,10 +397,10 @@ export function registerIpcHandlers(): void {
(
_event,
payload: {
- searchSpaceId?: number | null;
+ workspaceId?: number | null;
settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null };
}
- ) => setAgentFilesystemSettings(payload?.searchSpaceId, payload?.settings ?? {})
+ ) => setAgentFilesystemSettings(payload?.workspaceId, payload?.settings ?? {})
);
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () =>
@@ -415,7 +415,7 @@ export function registerIpcHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP,
- (_event, searchSpaceId?: number | null) =>
- stopAgentFilesystemTreeWatch(searchSpaceId)
+ (_event, workspaceId?: number | null) =>
+ stopAgentFilesystemTreeWatch(workspaceId)
);
}
diff --git a/surfsense_desktop/src/modules/active-search-space.ts b/surfsense_desktop/src/modules/active-search-space.ts
deleted file mode 100644
index e5f55c8f4..000000000
--- a/surfsense_desktop/src/modules/active-search-space.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-const STORE_KEY = 'activeSearchSpaceId';
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-let store: any = null;
-
-async function getStore() {
- if (!store) {
- const { default: Store } = await import('electron-store');
- store = new Store({
- name: 'active-search-space',
- defaults: { [STORE_KEY]: null as string | null },
- });
- }
- return store;
-}
-
-export async function getActiveSearchSpaceId(): Promise {
- const s = await getStore();
- return (s.get(STORE_KEY) as string | null) ?? null;
-}
-
-export async function setActiveSearchSpaceId(id: string): Promise {
- const s = await getStore();
- s.set(STORE_KEY, id);
-}
diff --git a/surfsense_desktop/src/modules/active-workspace.ts b/surfsense_desktop/src/modules/active-workspace.ts
new file mode 100644
index 000000000..82adb4de2
--- /dev/null
+++ b/surfsense_desktop/src/modules/active-workspace.ts
@@ -0,0 +1,33 @@
+const STORE_KEY = 'activeWorkspaceId';
+let store: any = null;
+
+async function getStore() {
+ if (!store) {
+ const { default: Store } = await import('electron-store');
+ store = new Store({
+ name: 'active-workspace',
+ defaults: { [STORE_KEY]: null as string | null },
+ });
+ // One-time migration from the legacy `active-search-space` store so the
+ // user's last-selected workspace survives the rename.
+ if (store.get(STORE_KEY) == null) {
+ const legacy: any = new Store({
+ name: 'active-search-space',
+ defaults: { activeSearchSpaceId: null as string | null },
+ });
+ const prev = legacy.get('activeSearchSpaceId') as string | null;
+ if (prev != null) store.set(STORE_KEY, prev);
+ }
+ }
+ return store;
+}
+
+export async function getActiveWorkspaceId(): Promise {
+ const s = await getStore();
+ return (s.get(STORE_KEY) as string | null) ?? null;
+}
+
+export async function setActiveWorkspaceId(id: string): Promise {
+ const s = await getStore();
+ s.set(STORE_KEY, id);
+}
diff --git a/surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts b/surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts
index 600f84fd5..6235e0342 100644
--- a/surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts
+++ b/surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts
@@ -8,7 +8,7 @@ const SAFETY_POLL_MS = 60_000;
const EVENT_DEBOUNCE_MS = 700;
export type AgentFilesystemTreeWatchOptions = {
- searchSpaceId?: number | null;
+ workspaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
@@ -17,7 +17,7 @@ export type AgentFilesystemTreeWatchOptions = {
type TreeDirtyReason = 'watcher_event' | 'safety_poll';
type TreeDirtyEvent = {
- searchSpaceId: number | null;
+ workspaceId: number | null;
reason: TreeDirtyReason;
rootPath: string;
changedPath: string | null;
@@ -25,7 +25,7 @@ type TreeDirtyEvent = {
};
type WatchSession = {
- searchSpaceId: number | null;
+ workspaceId: number | null;
optionsSignature: string;
rootPaths: string[];
excludePatterns: string[];
@@ -40,15 +40,15 @@ type WatchSession = {
const sessions = new Map();
-function normalizeSearchSpaceId(searchSpaceId?: number | null): number | null {
- if (typeof searchSpaceId === 'number' && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
- return searchSpaceId;
+function normalizeWorkspaceId(workspaceId?: number | null): number | null {
+ if (typeof workspaceId === 'number' && Number.isFinite(workspaceId) && workspaceId > 0) {
+ return workspaceId;
}
return null;
}
-function getSessionKey(searchSpaceId?: number | null): string {
- const normalized = normalizeSearchSpaceId(searchSpaceId);
+function getSessionKey(workspaceId?: number | null): string {
+ const normalized = normalizeWorkspaceId(workspaceId);
return normalized === null ? 'default' : String(normalized);
}
@@ -71,13 +71,13 @@ function normalizeExtensions(value: string[] | null | undefined): string[] | nul
}
function buildOptionsSignature(
- searchSpaceId: number | null,
+ workspaceId: number | null,
rootPaths: string[],
excludePatterns: string[],
fileExtensions: string[] | null
): string {
return JSON.stringify({
- searchSpaceId,
+ workspaceId,
rootPaths: [...rootPaths].sort(),
excludePatterns: [...excludePatterns].sort(),
fileExtensions: fileExtensions ? [...fileExtensions].sort() : null,
@@ -99,10 +99,10 @@ async function buildRootSnapshotSignature(
rootPath: string
): Promise {
let hash = 2166136261;
- hash = hashText(`space:${session.searchSpaceId ?? 'default'}|root:${rootPath}`, hash);
+ hash = hashText(`space:${session.workspaceId ?? 'default'}|root:${rootPath}`, hash);
const files = await listAgentFilesystemFiles({
rootPath,
- searchSpaceId: session.searchSpaceId,
+ workspaceId: session.workspaceId,
excludePatterns: session.excludePatterns,
fileExtensions: session.fileExtensions,
});
@@ -118,13 +118,13 @@ async function buildRootSnapshotSignature(
}
function sendTreeDirtyEvent(
- searchSpaceId: number | null,
+ workspaceId: number | null,
reason: TreeDirtyReason,
rootPath: string,
changedPath: string | null
): void {
const payload: TreeDirtyEvent = {
- searchSpaceId,
+ workspaceId,
reason,
rootPath,
changedPath,
@@ -158,7 +158,7 @@ function scheduleDirtyEmit(
session.pendingDirtyByRoot.clear();
for (const [pendingRootPath, payload] of pending) {
sendTreeDirtyEvent(
- session.searchSpaceId,
+ session.workspaceId,
payload.reason,
pendingRootPath,
payload.changedPath
@@ -183,21 +183,21 @@ async function closeSession(session: WatchSession): Promise {
export async function startAgentFilesystemTreeWatch(
options: AgentFilesystemTreeWatchOptions
): Promise<{ ok: true }> {
- const searchSpaceId = normalizeSearchSpaceId(options.searchSpaceId);
+ const workspaceId = normalizeWorkspaceId(options.workspaceId);
const rootPaths = Array.from(
new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath)))
);
const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns)));
const fileExtensions = normalizeExtensions(options.fileExtensions);
- const sessionKey = getSessionKey(searchSpaceId);
+ const sessionKey = getSessionKey(workspaceId);
if (rootPaths.length === 0) {
- await stopAgentFilesystemTreeWatch(searchSpaceId);
+ await stopAgentFilesystemTreeWatch(workspaceId);
return { ok: true };
}
const optionsSignature = buildOptionsSignature(
- searchSpaceId,
+ workspaceId,
rootPaths,
excludePatterns,
fileExtensions
@@ -228,7 +228,7 @@ export async function startAgentFilesystemTreeWatch(
);
const session: WatchSession = {
- searchSpaceId,
+ workspaceId,
optionsSignature,
rootPaths,
excludePatterns,
@@ -291,9 +291,9 @@ export async function startAgentFilesystemTreeWatch(
}
export async function stopAgentFilesystemTreeWatch(
- searchSpaceId?: number | null
+ workspaceId?: number | null
): Promise<{ ok: true }> {
- const sessionKey = getSessionKey(searchSpaceId);
+ const sessionKey = getSessionKey(workspaceId);
const session = sessions.get(sessionKey);
if (!session) return { ok: true };
sessions.delete(sessionKey);
diff --git a/surfsense_desktop/src/modules/agent-filesystem.ts b/surfsense_desktop/src/modules/agent-filesystem.ts
index 608f8c4a4..7a24876f2 100644
--- a/surfsense_desktop/src/modules/agent-filesystem.ts
+++ b/surfsense_desktop/src/modules/agent-filesystem.ts
@@ -119,9 +119,9 @@ async function normalizeLocalRootPathsCanonical(paths: unknown): Promise 0) {
- return String(searchSpaceId);
+function normalizeWorkspaceKey(workspaceId?: number | null): string {
+ if (typeof workspaceId === "number" && Number.isFinite(workspaceId) && workspaceId > 0) {
+ return String(workspaceId);
}
return DEFAULT_SPACE_KEY;
}
@@ -147,9 +147,9 @@ function getDefaultStore(): AgentFilesystemSettingsStore {
function getSettingsFromStore(
store: AgentFilesystemSettingsStore,
- searchSpaceId?: number | null
+ workspaceId?: number | null
): AgentFilesystemSettings {
- const key = normalizeSearchSpaceKey(searchSpaceId);
+ const key = normalizeWorkspaceKey(workspaceId);
return store.spaces[key] ?? getDefaultSettings();
}
@@ -194,22 +194,22 @@ async function loadAgentFilesystemSettingsStore(): Promise {
const store = await loadAgentFilesystemSettingsStore();
- return getSettingsFromStore(store, searchSpaceId);
+ return getSettingsFromStore(store, workspaceId);
}
export async function setAgentFilesystemSettings(
- searchSpaceId: number | null | undefined,
+ workspaceId: number | null | undefined,
settings: {
mode?: AgentFilesystemMode;
localRootPaths?: string[] | null;
}
): Promise {
const store = await loadAgentFilesystemSettingsStore();
- const key = normalizeSearchSpaceKey(searchSpaceId);
- const current = getSettingsFromStore(store, searchSpaceId);
+ const key = normalizeWorkspaceKey(workspaceId);
+ const current = getSettingsFromStore(store, workspaceId);
const nextMode =
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
? settings.mode
@@ -291,7 +291,7 @@ export type LocalRootMount = {
export type AgentFilesystemListOptions = {
rootPath: string;
- searchSpaceId?: number | null;
+ workspaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
};
@@ -332,9 +332,9 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
}
export async function getAgentFilesystemMounts(
- searchSpaceId?: number | null
+ workspaceId?: number | null
): Promise {
- const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
+ const rootPaths = await resolveCurrentRootPaths(workspaceId);
return buildRootMounts(rootPaths);
}
@@ -371,7 +371,7 @@ function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<
export async function listAgentFilesystemFiles(
options: AgentFilesystemListOptions
): Promise {
- const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
+ const allowedRootPaths = await resolveCurrentRootPaths(options.workspaceId);
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
const allowedRoots = new Set(
@@ -474,8 +474,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str
return `/${mount}${relativePath}`;
}
-async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise {
- const settings = await getAgentFilesystemSettings(searchSpaceId);
+async function resolveCurrentRootPaths(workspaceId?: number | null): Promise {
+ const settings = await getAgentFilesystemSettings(workspaceId);
if (settings.localRootPaths.length === 0) {
throw new Error("No local filesystem roots selected");
}
@@ -484,9 +484,9 @@ async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise {
- const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
+ const rootPaths = await resolveCurrentRootPaths(workspaceId);
const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount);
@@ -507,9 +507,9 @@ export async function readAgentLocalFileText(
export async function writeAgentLocalFileText(
virtualPath: string,
content: string,
- searchSpaceId?: number | null
+ workspaceId?: number | null
): Promise<{ path: string }> {
- const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
+ const rootPaths = await resolveCurrentRootPaths(workspaceId);
const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount);
diff --git a/surfsense_desktop/src/modules/folder-watcher.ts b/surfsense_desktop/src/modules/folder-watcher.ts
index ee4214d8a..4115b8db5 100644
--- a/surfsense_desktop/src/modules/folder-watcher.ts
+++ b/surfsense_desktop/src/modules/folder-watcher.ts
@@ -5,6 +5,7 @@ import * as path from 'path';
import * as fs from 'fs';
import { IPC_CHANNELS } from '../ipc/channels';
import { trackEvent } from './analytics';
+import { migrateWatchedFolderConfigs } from './migrate-watched-folders';
export interface WatchedFolderConfig {
path: string;
@@ -12,7 +13,7 @@ export interface WatchedFolderConfig {
excludePatterns: string[];
fileExtensions: string[] | null;
rootFolderId: number | null;
- searchSpaceId: number;
+ workspaceId: number;
active: boolean;
}
@@ -27,7 +28,7 @@ type FolderSyncAction = 'add' | 'change' | 'unlink';
export interface FolderSyncFileChangedEvent {
id: string;
rootFolderId: number | null;
- searchSpaceId: number;
+ workspaceId: number;
folderPath: string;
folderName: string;
relativePath: string;
@@ -68,6 +69,12 @@ async function getStore() {
[STORE_KEY]: [] as WatchedFolderConfig[],
},
});
+ // One-time read-migration: legacy persisted configs stored the workspace as
+ // `searchSpaceId`. Map it to `workspaceId` and write back once so existing
+ // watched folders keep their sync target after the rename.
+ const raw = store.get(STORE_KEY, []) as Array>;
+ const { configs, migrated } = migrateWatchedFolderConfigs(raw);
+ if (migrated) store.set(STORE_KEY, configs);
}
return store;
}
@@ -267,7 +274,7 @@ async function startWatcher(config: WatchedFolderConfig) {
if (storedMtime === undefined) {
sendFileChangedEvent({
rootFolderId: config.rootFolderId,
- searchSpaceId: config.searchSpaceId,
+ workspaceId: config.workspaceId,
folderPath: config.path,
folderName: config.name,
relativePath: rel,
@@ -278,7 +285,7 @@ async function startWatcher(config: WatchedFolderConfig) {
} else if (Math.abs(currentMtime - storedMtime) >= MTIME_TOLERANCE_S * 1000) {
sendFileChangedEvent({
rootFolderId: config.rootFolderId,
- searchSpaceId: config.searchSpaceId,
+ workspaceId: config.workspaceId,
folderPath: config.path,
folderName: config.name,
relativePath: rel,
@@ -295,7 +302,7 @@ async function startWatcher(config: WatchedFolderConfig) {
if (!(rel in currentMap)) {
sendFileChangedEvent({
rootFolderId: config.rootFolderId,
- searchSpaceId: config.searchSpaceId,
+ workspaceId: config.workspaceId,
folderPath: config.path,
folderName: config.name,
relativePath: rel,
@@ -346,7 +353,7 @@ async function startWatcher(config: WatchedFolderConfig) {
sendFileChangedEvent({
rootFolderId: config.rootFolderId,
- searchSpaceId: config.searchSpaceId,
+ workspaceId: config.workspaceId,
folderPath: config.path,
folderName: config.name,
relativePath,
@@ -403,7 +410,7 @@ export async function addWatchedFolder(
}
trackEvent('desktop_folder_watch_added', {
- search_space_id: config.searchSpaceId,
+ workspace_id: config.workspaceId,
root_folder_id: config.rootFolderId,
active: config.active,
has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0,
@@ -431,7 +438,7 @@ export async function removeWatchedFolder(
if (removed) {
trackEvent('desktop_folder_watch_removed', {
- search_space_id: removed.searchSpaceId,
+ workspace_id: removed.workspaceId,
root_folder_id: removed.rootFolderId,
});
}
diff --git a/surfsense_desktop/src/modules/migrate-watched-folders.test.ts b/surfsense_desktop/src/modules/migrate-watched-folders.test.ts
new file mode 100644
index 000000000..cd7b3623f
--- /dev/null
+++ b/surfsense_desktop/src/modules/migrate-watched-folders.test.ts
@@ -0,0 +1,21 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { migrateWatchedFolderConfigs } from "./migrate-watched-folders.ts";
+
+// Run with: node --test src/modules/migrate-watched-folders.test.ts
+test("maps legacy searchSpaceId to workspaceId and flags migration", () => {
+ const { configs, migrated } = migrateWatchedFolderConfigs<{ workspaceId?: number }>([
+ { path: "/tmp/a", searchSpaceId: 42 },
+ ]);
+ assert.equal(migrated, true);
+ assert.equal(configs[0].workspaceId, 42);
+ assert.equal("searchSpaceId" in (configs[0] as object), false);
+});
+
+test("leaves configs that already have workspaceId untouched", () => {
+ const { configs, migrated } = migrateWatchedFolderConfigs<{ workspaceId?: number }>([
+ { path: "/tmp/b", workspaceId: 5 },
+ ]);
+ assert.equal(migrated, false);
+ assert.equal(configs[0].workspaceId, 5);
+});
diff --git a/surfsense_desktop/src/modules/migrate-watched-folders.ts b/surfsense_desktop/src/modules/migrate-watched-folders.ts
new file mode 100644
index 000000000..2aa30fcd3
--- /dev/null
+++ b/surfsense_desktop/src/modules/migrate-watched-folders.ts
@@ -0,0 +1,23 @@
+/**
+ * One-time read-migration for persisted watched-folder configs: legacy configs
+ * stored the workspace as `searchSpaceId`. Map it to `workspaceId` so existing
+ * watched folders keep their sync target after the rename. Pure + dependency-free
+ * so it can be unit-checked without loading electron-store.
+ *
+ * Returns the migrated configs and whether anything changed (so callers can
+ * write back only when needed).
+ */
+export function migrateWatchedFolderConfigs(
+ raw: Array>
+): { configs: T[]; migrated: boolean } {
+ let migrated = false;
+ const configs = raw.map((c) => {
+ if (c.workspaceId === undefined && c.searchSpaceId !== undefined) {
+ migrated = true;
+ const { searchSpaceId, ...rest } = c;
+ return { ...rest, workspaceId: searchSpaceId } as unknown as T;
+ }
+ return c as unknown as T;
+ });
+ return { configs, migrated };
+}
diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts
index 0807e2e08..4b48a3d19 100644
--- a/surfsense_desktop/src/modules/quick-ask.ts
+++ b/surfsense_desktop/src/modules/quick-ask.ts
@@ -4,14 +4,14 @@ import { IPC_CHANNELS } from '../ipc/channels';
import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
import { getServerOrigin } from './server';
import { getShortcuts } from './shortcuts';
-import { getActiveSearchSpaceId } from './active-search-space';
+import { getActiveWorkspaceId } from './active-workspace';
import { trackEvent } from './analytics';
let currentShortcut = '';
let quickAskWindow: BrowserWindow | null = null;
let pendingText = '';
let pendingMode = '';
-let pendingSearchSpaceId: string | null = null;
+let pendingWorkspaceId: string | null = null;
let sourceApp = '';
let savedClipboard = '';
@@ -57,7 +57,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
skipTaskbar: true,
});
- const spaceId = pendingSearchSpaceId;
+ const spaceId = pendingWorkspaceId;
const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard';
quickAskWindow.loadURL(`${getServerOrigin()}${route}?quickAssist=true`);
@@ -87,7 +87,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
async function openQuickAsk(text: string): Promise {
pendingText = text;
pendingMode = 'quick-assist';
- pendingSearchSpaceId = await getActiveSearchSpaceId();
+ pendingWorkspaceId = await getActiveWorkspaceId();
const cursor = screen.getCursorScreenPoint();
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
createQuickAskWindow(pos.x, pos.y);
diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts
index bfcd9b512..3ab47fb58 100644
--- a/surfsense_desktop/src/modules/window.ts
+++ b/surfsense_desktop/src/modules/window.ts
@@ -3,7 +3,7 @@ import path from 'path';
import { trackEvent } from './analytics';
import { showErrorDialog } from './errors';
import { getServerOrigin, getServerPort } from './server';
-import { setActiveSearchSpaceId } from './active-search-space';
+import { setActiveWorkspaceId } from './active-workspace';
const isDev = !app.isPackaged;
const isMac = process.platform === 'darwin';
@@ -140,14 +140,14 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
});
// Auto-sync active search space from URL navigation
- const syncSearchSpace = (url: string) => {
+ const syncWorkspace = (url: string) => {
const match = url.match(/\/dashboard\/(\d+)/);
if (match) {
- setActiveSearchSpaceId(match[1]);
+ setActiveWorkspaceId(match[1]);
}
};
- mainWindow.webContents.on('did-navigate', (_event, url) => syncSearchSpace(url));
- mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncSearchSpace(url));
+ mainWindow.webContents.on('did-navigate', (_event, url) => syncWorkspace(url));
+ mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncWorkspace(url));
if (isDev) {
mainWindow.webContents.openDevTools();
diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts
index 07f363a59..96079d241 100644
--- a/surfsense_desktop/src/preload.ts
+++ b/surfsense_desktop/src/preload.ts
@@ -74,10 +74,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Browse files via native dialog
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
- readAgentLocalFileText: (virtualPath: string, searchSpaceId?: number | null) =>
- ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, searchSpaceId),
- writeAgentLocalFileText: (virtualPath: string, content: string, searchSpaceId?: number | null) =>
- ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId),
+ readAgentLocalFileText: (virtualPath: string, workspaceId?: number | null) =>
+ ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, workspaceId),
+ writeAgentLocalFileText: (virtualPath: string, content: string, workspaceId?: number | null) =>
+ ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, workspaceId),
// Auth token sync across windows
getAccessToken: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACCESS_TOKEN),
@@ -104,9 +104,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }),
// Active search space
- getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE),
- setActiveSearchSpace: (id: string) =>
- ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id),
+ getActiveWorkspace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_WORKSPACE),
+ setActiveWorkspace: (id: string) =>
+ ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_WORKSPACE, id),
// Analytics bridge — lets posthog-js running inside the Next.js renderer
// mirror identify/reset/capture into the Electron main-process PostHog
@@ -118,27 +118,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
// Agent filesystem mode
- getAgentFilesystemSettings: (searchSpaceId?: number | null) =>
- ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, searchSpaceId),
- getAgentFilesystemMounts: (searchSpaceId?: number | null) =>
- ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, searchSpaceId),
+ getAgentFilesystemSettings: (workspaceId?: number | null) =>
+ ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, workspaceId),
+ getAgentFilesystemMounts: (workspaceId?: number | null) =>
+ ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, workspaceId),
listAgentFilesystemFiles: (options: {
rootPath: string;
- searchSpaceId?: number | null;
+ workspaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options),
startAgentFilesystemTreeWatch: (options: {
- searchSpaceId?: number | null;
+ workspaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options),
- stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) =>
- ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, searchSpaceId),
+ stopAgentFilesystemTreeWatch: (workspaceId?: number | null) =>
+ ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, workspaceId),
onAgentFilesystemTreeDirty: (
callback: (data: {
- searchSpaceId: number | null;
+ workspaceId: number | null;
reason: 'watcher_event' | 'safety_poll';
rootPath: string;
changedPath: string | null;
@@ -148,7 +148,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (
_event: unknown,
data: {
- searchSpaceId: number | null;
+ workspaceId: number | null;
reason: 'watcher_event' | 'safety_poll';
rootPath: string;
changedPath: string | null;
@@ -163,7 +163,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
setAgentFilesystemSettings: (settings: {
mode?: "cloud" | "desktop_local_folder";
localRootPaths?: string[] | null;
- }, searchSpaceId?: number | null) =>
- ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { searchSpaceId, settings }),
+ }, workspaceId?: number | null) =>
+ ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { workspaceId, settings }),
pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT),
});
diff --git a/surfsense_desktop/tsconfig.json b/surfsense_desktop/tsconfig.json
index a7862e222..4315c7571 100644
--- a/surfsense_desktop/tsconfig.json
+++ b/surfsense_desktop/tsconfig.json
@@ -12,5 +12,5 @@
"noEmit": true
},
"include": ["src/**/*.ts"],
- "exclude": ["node_modules", "dist", "scripts"]
+ "exclude": ["node_modules", "dist", "scripts", "src/**/*.test.ts"]
}
diff --git a/surfsense_mcp/.env.example b/surfsense_mcp/.env.example
new file mode 100644
index 000000000..3267324f4
--- /dev/null
+++ b/surfsense_mcp/.env.example
@@ -0,0 +1,16 @@
+# Copy to .env (or set these in your MCP client config) and fill in your token.
+
+# API key from SurfSense (Settings -> API). Required.
+SURFSENSE_API_KEY=ss_pat_your_token_here
+
+# Base URL of the SurfSense backend. Defaults to http://localhost:8000
+SURFSENSE_BASE_URL=http://localhost:8000
+
+# Optional: default workspace (search space) by name or id, so you don't have
+# to select one each session.
+# SURFSENSE_WORKSPACE=My Research Space
+
+# Optional overrides.
+# SURFSENSE_API_PREFIX=/api/v1
+# SURFSENSE_TIMEOUT=180
+# SURFSENSE_MCP_TRANSPORT=stdio
diff --git a/surfsense_mcp/.gitignore b/surfsense_mcp/.gitignore
new file mode 100644
index 000000000..c798b27c0
--- /dev/null
+++ b/surfsense_mcp/.gitignore
@@ -0,0 +1,7 @@
+.venv/
+.env
+__pycache__/
+*.pyc
+dist/
+build/
+*.egg-info/
diff --git a/surfsense_mcp/README.md b/surfsense_mcp/README.md
new file mode 100644
index 000000000..47dabf55c
--- /dev/null
+++ b/surfsense_mcp/README.md
@@ -0,0 +1,92 @@
+# SurfSense MCP Server
+
+A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes
+SurfSense to MCP clients like **Claude Code**, **Cursor**, and **Claude Desktop**.
+It talks to a running SurfSense backend purely over its REST API using a SurfSense
+API key — it imports no backend code and can point at any instance (local or
+hosted) by changing two environment variables.
+
+## Tools
+
+**Search-space selector**
+- `surfsense_list_workspaces` — list the workspaces (search spaces) you can access
+- `surfsense_select_workspace` — pick the active workspace by name or id
+
+**Scrapers (all platforms)**
+- `surfsense_web_crawl`, `surfsense_google_search`, `surfsense_reddit_scrape`,
+ `surfsense_youtube_scrape`, `surfsense_youtube_comments`,
+ `surfsense_google_maps_scrape`, `surfsense_google_maps_reviews`
+- `surfsense_list_scraper_runs`, `surfsense_get_scraper_run` — retrieve past
+ results in full (useful when a large result was truncated inline)
+
+**Knowledge base**
+- `surfsense_search_knowledge_base` — semantic + keyword search over stored content
+- `surfsense_list_documents`, `surfsense_get_document`
+- `surfsense_add_document`, `surfsense_upload_file`
+- `surfsense_update_document`, `surfsense_delete_document`
+
+Workspace-scoped tools default to the active workspace; pass `workspace` (a name
+or id) to override for a single call. Ids never need to be typed by hand — the
+model carries them between calls.
+
+## Prerequisites
+
+1. A running SurfSense backend (default `http://localhost:8000`).
+2. A **SurfSense API key**: SurfSense → Settings → API → create key (`ss_pat_…`).
+3. **API access enabled** on the workspace(s) you want to use (workspace settings).
+
+## Setup
+
+Uses [uv](https://github.com/astral-sh/uv):
+
+```bash
+cd surfsense_mcp
+uv sync
+uv run python -m surfsense_mcp.selfcheck # verify tools register correctly
+```
+
+## Connect it to a client
+
+### Cursor
+
+Add to `~/.cursor/mcp.json` (or a project `.cursor/mcp.json`):
+
+```json
+{
+ "mcpServers": {
+ "surfsense": {
+ "command": "uv",
+ "args": ["run", "--directory", "/absolute/path/to/SurfSense/surfsense_mcp", "python", "-m", "surfsense_mcp"],
+ "env": {
+ "SURFSENSE_BASE_URL": "http://localhost:8000",
+ "SURFSENSE_API_KEY": "ss_pat_your_token_here"
+ }
+ }
+ }
+}
+```
+
+### Claude Code
+
+```bash
+claude mcp add surfsense \
+ -e SURFSENSE_BASE_URL=http://localhost:8000 \
+ -e SURFSENSE_API_KEY=ss_pat_your_token_here \
+ -- uv run --directory /absolute/path/to/SurfSense/surfsense_mcp python -m surfsense_mcp
+```
+
+### Claude Desktop
+
+Add the same `mcpServers` block as Cursor to
+`claude_desktop_config.json` (Settings → Developer → Edit Config).
+
+## Configuration
+
+See `.env.example`. Secrets are passed as environment variables by the client;
+never commit tokens.
+
+## Backend dependency
+
+`surfsense_search_knowledge_base` calls `POST /api/v1/documents/search-semantic`,
+a thin endpoint that exposes the backend's existing hybrid retriever over REST.
+All other tools use pre-existing SurfSense endpoints.
diff --git a/surfsense_mcp/pyproject.toml b/surfsense_mcp/pyproject.toml
new file mode 100644
index 000000000..87db0c223
--- /dev/null
+++ b/surfsense_mcp/pyproject.toml
@@ -0,0 +1,27 @@
+[project]
+name = "surfsense-mcp"
+version = "0.1.0"
+description = "MCP server exposing SurfSense scrapers, knowledge base, and workspace tools over the REST API."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+dependencies = [
+ "mcp>=1.26.0",
+ "httpx>=0.27.0",
+]
+
+[project.scripts]
+surfsense-mcp = "surfsense_mcp.__main__:main"
+
+[dependency-groups]
+dev = ["pytest>=8.0"]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/surfsense_mcp"]
+
+[tool.ruff]
+target-version = "py311"
diff --git a/surfsense_mcp/src/surfsense_mcp/__init__.py b/surfsense_mcp/src/surfsense_mcp/__init__.py
new file mode 100644
index 000000000..24f6ebae4
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/__init__.py
@@ -0,0 +1,8 @@
+"""SurfSense MCP server.
+
+Exposes SurfSense's scrapers, knowledge base, and workspaces to MCP clients
+(Claude Code, Cursor, Claude Desktop). Connects to a SurfSense backend over its
+REST API, authenticating with a SurfSense API key.
+"""
+
+__version__ = "0.1.0"
diff --git a/surfsense_mcp/src/surfsense_mcp/__main__.py b/surfsense_mcp/src/surfsense_mcp/__main__.py
new file mode 100644
index 000000000..6c878ebfa
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/__main__.py
@@ -0,0 +1,31 @@
+"""Entry point: load settings from the environment and run the MCP server.
+
+Defaults to stdio (what Cursor, Claude Code, and Claude Desktop launch). stdout
+is the protocol channel, so every log line goes to stderr.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+
+from .config import Settings
+from .server import build_server
+
+
+def main() -> None:
+ logging.basicConfig(
+ level=logging.INFO,
+ stream=sys.stderr,
+ format="%(levelname)s %(name)s: %(message)s",
+ )
+ settings = Settings.from_env()
+ mcp, _client = build_server(settings)
+
+ transport = os.environ.get("SURFSENSE_MCP_TRANSPORT", "stdio").strip() or "stdio"
+ mcp.run(transport=transport)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/surfsense_mcp/src/surfsense_mcp/config.py b/surfsense_mcp/src/surfsense_mcp/config.py
new file mode 100644
index 000000000..5d3646545
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/config.py
@@ -0,0 +1,62 @@
+"""Runtime configuration, read once from the environment.
+
+Secrets never live in code or client config files — the client (Cursor/Claude)
+passes them as environment variables when it launches this server (see README).
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+
+DEFAULT_BASE_URL = "http://localhost:8000"
+DEFAULT_API_PREFIX = "/api/v1"
+DEFAULT_TIMEOUT_SECONDS = 180.0
+
+
+@dataclass(frozen=True)
+class Settings:
+ """Resolved configuration for a server process."""
+
+ base_url: str
+ api_key: str
+ api_prefix: str
+ timeout: float
+ default_workspace: str | None
+
+ @property
+ def api_base(self) -> str:
+ return f"{self.base_url}{self.api_prefix}"
+
+ @classmethod
+ def from_env(cls) -> Settings:
+ api_key = os.environ.get("SURFSENSE_API_KEY", "").strip()
+ if not api_key:
+ raise SystemExit(
+ "SURFSENSE_API_KEY is required. Create an API key in SurfSense "
+ "(Settings -> API) and pass it via the SURFSENSE_API_KEY "
+ "environment variable."
+ )
+
+ base_url = (
+ os.environ.get("SURFSENSE_BASE_URL", DEFAULT_BASE_URL).strip().rstrip("/")
+ )
+ api_prefix = "/" + os.environ.get(
+ "SURFSENSE_API_PREFIX", DEFAULT_API_PREFIX
+ ).strip().strip("/")
+
+ raw_timeout = os.environ.get("SURFSENSE_TIMEOUT", "").strip()
+ try:
+ timeout = float(raw_timeout) if raw_timeout else DEFAULT_TIMEOUT_SECONDS
+ except ValueError:
+ timeout = DEFAULT_TIMEOUT_SECONDS
+
+ default_workspace = os.environ.get("SURFSENSE_WORKSPACE", "").strip() or None
+
+ return cls(
+ base_url=base_url,
+ api_key=api_key,
+ api_prefix=api_prefix,
+ timeout=timeout,
+ default_workspace=default_workspace,
+ )
diff --git a/surfsense_mcp/src/surfsense_mcp/core/__init__.py b/surfsense_mcp/src/surfsense_mcp/core/__init__.py
new file mode 100644
index 000000000..4580e4ce7
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/core/__init__.py
@@ -0,0 +1 @@
+"""Cross-feature infrastructure: transport, workspace resolution, output shaping."""
diff --git a/surfsense_mcp/src/surfsense_mcp/core/client.py b/surfsense_mcp/src/surfsense_mcp/core/client.py
new file mode 100644
index 000000000..55f9d6048
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/core/client.py
@@ -0,0 +1,104 @@
+"""Authenticated transport to a SurfSense backend's REST API.
+
+Sends requests to fully-formed paths, returns parsed JSON, and turns any
+transport or HTTP failure into a readable ``ToolError``.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+
+from .errors import ToolError
+
+_FAILURE_HINTS: dict[int, str] = {
+ 401: "Authentication failed — check that SURFSENSE_API_KEY is a valid, unexpired key.",
+ 402: "The workspace is out of credits for this operation.",
+ 403: (
+ "Access denied — the token lacks permission, or API access is disabled "
+ "for this workspace (enable it in SurfSense workspace settings)."
+ ),
+ 404: "The requested resource was not found.",
+ 429: "Rate limited by the backend — retry after a short pause.",
+}
+
+
+class SurfSenseClient:
+ """Issues authenticated requests against ``{base_url}{api_prefix}``."""
+
+ def __init__(self, *, api_base: str, api_key: str, timeout: float) -> None:
+ self._api_base = api_base
+ self._http = httpx.AsyncClient(
+ base_url=api_base,
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Accept": "application/json",
+ },
+ timeout=timeout,
+ )
+
+ async def request(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: dict[str, Any] | None = None,
+ json: Any | None = None,
+ data: dict[str, Any] | None = None,
+ files: Any | None = None,
+ ) -> Any:
+ """Send a request and return the parsed body, or raise ``ToolError``."""
+ # Omit unset query params: sending them empty makes the API parse ""
+ # as a value (e.g. int("") on folder_id) and fail.
+ if params is not None:
+ params = {key: value for key, value in params.items() if value is not None}
+ try:
+ response = await self._http.request(
+ method, path, params=params, json=json, data=data, files=files
+ )
+ except httpx.RequestError as exc:
+ raise ToolError(
+ f"Could not reach SurfSense at {self._api_base}: {exc}. "
+ "Confirm the backend is running and SURFSENSE_BASE_URL is correct."
+ ) from exc
+
+ if response.is_success:
+ return self._parse_body(response)
+ raise ToolError(self._explain_failure(response))
+
+ async def aclose(self) -> None:
+ await self._http.aclose()
+
+ @staticmethod
+ def _parse_body(response: httpx.Response) -> Any:
+ if not response.content:
+ return None
+ try:
+ return response.json()
+ except ValueError:
+ return response.text
+
+ @classmethod
+ def _explain_failure(cls, response: httpx.Response) -> str:
+ """Turn an error response into one actionable sentence for the model."""
+ detail = cls._extract_detail(response)
+ hint = _FAILURE_HINTS.get(response.status_code)
+ if detail and hint:
+ return f"{hint} (server said: {detail})"
+ if detail:
+ return f"SurfSense returned {response.status_code}: {detail}"
+ return hint or f"SurfSense returned HTTP {response.status_code}."
+
+ @staticmethod
+ def _extract_detail(response: httpx.Response) -> str | None:
+ try:
+ body = response.json()
+ except ValueError:
+ return response.text.strip() or None
+ if isinstance(body, dict):
+ detail = body.get("detail", body)
+ if isinstance(detail, dict):
+ return detail.get("message") or str(detail)
+ return str(detail)
+ return str(body)
diff --git a/surfsense_mcp/src/surfsense_mcp/core/errors.py b/surfsense_mcp/src/surfsense_mcp/core/errors.py
new file mode 100644
index 000000000..428def846
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/core/errors.py
@@ -0,0 +1,12 @@
+"""The single failure type tools raise to speak plainly to the model.
+
+A failed tool call should tell the model what to do next, not leak a stack
+trace. Anything the caller could act on — no workspace selected, an unknown id,
+a rejected request — is raised as ``ToolError`` with a sentence safe to surface.
+"""
+
+from __future__ import annotations
+
+
+class ToolError(Exception):
+ """A user-actionable failure whose message is meant for the model to read."""
diff --git a/surfsense_mcp/src/surfsense_mcp/core/rendering.py b/surfsense_mcp/src/surfsense_mcp/core/rendering.py
new file mode 100644
index 000000000..ebfdd9e71
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/core/rendering.py
@@ -0,0 +1,72 @@
+"""Shape tool results into the format the caller asked for.
+
+Tools default to Markdown (readable for the model and the human watching), and
+can return raw JSON when a caller wants to post-process the data. Large payloads
+are clipped so a single call can't blow the context window.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Annotated, Any, Literal
+
+from pydantic import Field
+
+ResponseFormat = Literal["markdown", "json"]
+
+# Shared parameter type for every tool: same name, same semantics everywhere.
+ResponseFormatParam = Annotated[
+ ResponseFormat,
+ Field(
+ description="'markdown' (default, human-readable) or 'json' "
+ "(raw data for post-processing)."
+ ),
+]
+
+DEFAULT_CLIP_CHARS = 20_000
+ITEM_FIELD_CLIP_CHARS = 1_500
+
+# Fields that duplicate another field verbatim (e.g. Reddit's 'html' mirrors
+# 'body') and only bloat inline results. The full record stays in the run.
+_REDUNDANT_ITEM_FIELDS = frozenset({"html"})
+
+
+def compact_items(result: Any, field_limit: int = ITEM_FIELD_CLIP_CHARS) -> Any:
+ """Shrink a scraper result for inline return.
+
+ Drops redundant fields and clips overlong strings per field, so a response
+ keeps every item as an excerpt instead of a few items in full. The
+ untruncated result remains retrievable via its stored run.
+ """
+ if isinstance(result, dict) and isinstance(result.get("items"), list):
+ return {
+ **result,
+ "items": [_compact_item(item, field_limit) for item in result["items"]],
+ }
+ return result
+
+
+def _compact_item(item: Any, field_limit: int) -> Any:
+ # ponytail: compacts top-level string fields only; nested structures pass
+ # through untouched. Upgrade path is a recursive walk if a platform nests
+ # long text.
+ if not isinstance(item, dict):
+ return item
+ return {
+ key: clip(value, field_limit) if isinstance(value, str) else value
+ for key, value in item.items()
+ if key not in _REDUNDANT_ITEM_FIELDS
+ }
+
+
+def to_json(payload: Any) -> str:
+ """Pretty-print a payload as JSON, tolerating non-serializable values."""
+ return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
+
+
+def clip(text: str, limit: int = DEFAULT_CLIP_CHARS) -> str:
+ """Trim overlong text, leaving a visible marker of how much was dropped."""
+ if len(text) <= limit:
+ return text
+ dropped = len(text) - limit
+ return f"{text[:limit]}\n\n… [{dropped} more characters truncated]"
diff --git a/surfsense_mcp/src/surfsense_mcp/core/workspace_context.py b/surfsense_mcp/src/surfsense_mcp/core/workspace_context.py
new file mode 100644
index 000000000..6682d53b5
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/core/workspace_context.py
@@ -0,0 +1,144 @@
+"""Active-workspace state and natural-language resolution of a workspace.
+
+Every workspace-scoped tool takes a workspace by name or id, or omits it to use
+the active one. This keeps ids out of the conversation: the model (or user)
+speaks a name, we resolve it, and remember the choice for later calls.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Annotated
+
+from pydantic import Field
+
+from .client import SurfSenseClient
+from .errors import ToolError
+
+# Shared parameter type for every workspace-scoped tool.
+WorkspaceParam = Annotated[
+ str | None,
+ Field(
+ description="Workspace name or id, e.g. 'Research' or '3'. Omit to use "
+ "the active workspace (set with surfsense_select_workspace)."
+ ),
+]
+
+
+@dataclass(frozen=True)
+class Workspace:
+ """A SurfSense workspace (the product's "search space")."""
+
+ id: int
+ name: str
+ description: str | None = None
+ is_owner: bool = False
+ member_count: int = 1
+
+
+class WorkspaceContext:
+ """Resolves workspace references and tracks the active selection."""
+
+ def __init__(
+ self, client: SurfSenseClient, *, preferred_reference: str | None = None
+ ) -> None:
+ self._client = client
+ self._preferred_reference = preferred_reference
+ self._active: Workspace | None = None
+
+ @property
+ def active(self) -> Workspace | None:
+ return self._active
+
+ def remember(self, workspace: Workspace) -> Workspace:
+ """Make ``workspace`` the default for later scoped calls."""
+ self._active = workspace
+ return workspace
+
+ async def fetch_all(self) -> list[Workspace]:
+ """List every workspace the token can access."""
+ rows = await self._client.request("GET", "/workspaces")
+ return [_to_workspace(row) for row in rows or []]
+
+ async def resolve(self, reference: str | int | None) -> Workspace:
+ """Resolve a name/id (or the active/preferred default) to a workspace."""
+ if reference is None or (isinstance(reference, str) and not reference.strip()):
+ return await self._resolve_default()
+ return self.remember(await self._match(reference))
+
+ async def _resolve_default(self) -> Workspace:
+ if self._active is not None:
+ return self._active
+ if self._preferred_reference:
+ return self.remember(await self._match(self._preferred_reference))
+ return self.remember(await self._only_workspace_or_prompt())
+
+ async def _only_workspace_or_prompt(self) -> Workspace:
+ workspaces = await self.fetch_all()
+ if len(workspaces) == 1:
+ return workspaces[0]
+ if not workspaces:
+ raise ToolError(
+ "No accessible workspaces. Confirm the token's account has a "
+ "workspace with API access enabled."
+ )
+ raise ToolError(
+ "No workspace selected. Choose one first with surfsense_select_workspace, "
+ f"or pass 'workspace'. Available: {_name_list(workspaces)}."
+ )
+
+ async def _match(self, reference: str | int) -> Workspace:
+ workspaces = await self.fetch_all()
+ as_id = _as_int(reference)
+ if as_id is not None:
+ found = next((w for w in workspaces if w.id == as_id), None)
+ if found is None:
+ raise ToolError(
+ f"No workspace with id {as_id}. Available: {_name_list(workspaces)}."
+ )
+ return found
+ return _match_by_name(str(reference), workspaces)
+
+
+def _match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace:
+ """Match on name: exact, then case-insensitive, then unique substring."""
+ needle = reference.strip()
+ exact = [w for w in workspaces if w.name == needle]
+ if exact:
+ return exact[0]
+ lowered = needle.casefold()
+ insensitive = [w for w in workspaces if w.name.casefold() == lowered]
+ if insensitive:
+ return insensitive[0]
+ partial = [w for w in workspaces if lowered in w.name.casefold()]
+ if len(partial) == 1:
+ return partial[0]
+ if len(partial) > 1:
+ raise ToolError(
+ f"'{reference}' matches several workspaces: {_name_list(partial)}. "
+ "Use a more specific name or the id."
+ )
+ raise ToolError(
+ f"No workspace named '{reference}'. Available: {_name_list(workspaces)}."
+ )
+
+
+def _to_workspace(row: dict) -> Workspace:
+ return Workspace(
+ id=row["id"],
+ name=row["name"],
+ description=row.get("description"),
+ is_owner=row.get("is_owner", False),
+ member_count=row.get("member_count", 1),
+ )
+
+
+def _as_int(reference: str | int) -> int | None:
+ if isinstance(reference, int):
+ return reference
+ text = reference.strip()
+ return int(text) if text.isdigit() else None
+
+
+def _name_list(workspaces: list[Workspace]) -> str:
+ return ", ".join(f"{w.name} (id {w.id})" for w in workspaces)
diff --git a/surfsense_mcp/src/surfsense_mcp/features/__init__.py b/surfsense_mcp/src/surfsense_mcp/features/__init__.py
new file mode 100644
index 000000000..0d3a683f6
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/features/__init__.py
@@ -0,0 +1 @@
+"""Vertical slices: one folder per capability group exposed to MCP clients."""
diff --git a/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py b/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py
new file mode 100644
index 000000000..7fb1802ae
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/__init__.py
@@ -0,0 +1,379 @@
+"""Knowledge-base tools: search the KB and manage its documents.
+
+Semantic search plus the document lifecycle — list, read, add text, upload a
+file, update, and delete — over a workspace's knowledge base. Search and reads
+default to the active workspace; document ids identify a single document across
+the whole account, so id-addressed tools need no workspace.
+"""
+
+from __future__ import annotations
+
+import mimetypes
+from pathlib import Path
+from typing import Annotated
+
+from mcp.server.fastmcp import FastMCP
+from mcp.types import ToolAnnotations
+from pydantic import Field
+
+from ...core.client import SurfSenseClient
+from ...core.errors import ToolError
+from ...core.rendering import ResponseFormatParam, clip, to_json
+from ...core.workspace_context import WorkspaceContext, WorkspaceParam
+from .note_ingestion import build_note_document
+
+_READ = ToolAnnotations(
+ readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
+)
+_WRITE = ToolAnnotations(
+ readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False
+)
+_DELETE = ToolAnnotations(
+ readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False
+)
+
+_DOCUMENT_ID = Annotated[
+ int,
+ Field(
+ description="Document id from surfsense_search_knowledge_base or "
+ "surfsense_list_documents results."
+ ),
+]
+
+_DOCUMENT_TYPES = Annotated[
+ list[str] | None,
+ Field(
+ description="Restrict to these document types, e.g. "
+ "['FILE', 'CRAWLED_URL', 'YOUTUBE_VIDEO']. Omit for all types."
+ ),
+]
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register the knowledge-base tools on the server."""
+
+ @mcp.tool(
+ name="surfsense_search_knowledge_base",
+ title="Search knowledge base",
+ annotations=_READ,
+ structured_output=False,
+ )
+ async def search_knowledge_base(
+ query: Annotated[
+ str,
+ Field(
+ min_length=1,
+ description="Natural-language search, e.g. "
+ "'notebooklm user complaints'.",
+ ),
+ ],
+ top_k: Annotated[
+ int, Field(ge=1, le=20, description="Maximum documents to return.")
+ ] = 5,
+ document_types: _DOCUMENT_TYPES = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Search the workspace's knowledge base by meaning and keywords.
+
+ Use this FIRST when a question might be answered by content already
+ stored in SurfSense — notes, uploaded files, saved pages, past
+ research. Do NOT use it to fetch new data from the web; use the
+ scraper tools for that. Returns the most relevant documents with the
+ passages that matched, ranked by relevance score.
+ Example: query='pricing feedback', top_k=5.
+ """
+ resolved = await context.resolve(workspace)
+ hits = await client.request(
+ "POST",
+ "/documents/search-semantic",
+ json={
+ "workspace_id": resolved.id,
+ "query": query,
+ "top_k": max(1, min(top_k, 20)),
+ "document_types": document_types,
+ },
+ )
+ items = (hits or {}).get("items", [])
+ if response_format == "json":
+ return to_json(items)
+ return _render_search(query, items)
+
+ @mcp.tool(
+ name="surfsense_list_documents",
+ title="List documents",
+ annotations=_READ,
+ structured_output=False,
+ )
+ async def list_documents(
+ document_types: _DOCUMENT_TYPES = None,
+ folder_id: Annotated[
+ int | None,
+ Field(description="Only documents in this folder. Omit for all."),
+ ] = None,
+ page: Annotated[
+ int, Field(ge=0, description="Zero-based page number.")
+ ] = 0,
+ page_size: Annotated[
+ int, Field(ge=1, description="Documents per page.")
+ ] = 20,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """List documents in the workspace's knowledge base, newest first.
+
+ Use this to browse or inventory what is stored; to find documents
+ about a topic, prefer surfsense_search_knowledge_base. Returns each
+ document's title, id, type, and update time, plus a has_more flag —
+ request the next page by increasing page.
+ Example: document_types=['FILE'], page=0, page_size=20.
+ """
+ resolved = await context.resolve(workspace)
+ result = await client.request(
+ "GET",
+ "/documents",
+ params={
+ "workspace_id": resolved.id,
+ "page": page,
+ "page_size": page_size,
+ "document_types": _join(document_types),
+ "folder_id": folder_id,
+ },
+ )
+ if response_format == "json":
+ return to_json(result)
+ return _render_document_list(result)
+
+ @mcp.tool(
+ name="surfsense_get_document",
+ title="Read one document",
+ annotations=_READ,
+ structured_output=False,
+ )
+ async def get_document(
+ document_id: _DOCUMENT_ID,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Read one document's full content and metadata by id.
+
+ Use this after surfsense_search_knowledge_base or
+ surfsense_list_documents to open a specific document — search results
+ only include the matching passages, this returns the whole text.
+ """
+ document = await client.request("GET", f"/documents/{document_id}")
+ if response_format == "json":
+ return clip(to_json(document))
+ return _render_document(document)
+
+ @mcp.tool(
+ name="surfsense_add_document",
+ title="Add a note",
+ annotations=_WRITE,
+ structured_output=False,
+ )
+ async def add_document(
+ title: Annotated[
+ str,
+ Field(min_length=1, description="Short descriptive title for the note."),
+ ],
+ content: Annotated[
+ str,
+ Field(
+ min_length=1,
+ description="The note's body; plain text or markdown.",
+ ),
+ ],
+ source_url: Annotated[
+ str | None,
+ Field(description="Where the text came from, if anywhere."),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ ) -> str:
+ """Save a text or markdown note into the workspace's knowledge base.
+
+ Use this to store notes, summaries, or findings so they become
+ searchable later — e.g. after finishing a piece of research. For files
+ on disk use surfsense_upload_file instead. Indexing is asynchronous,
+ so the note may take a moment to appear in search.
+ Example: title='NotebookLM subreddits', content='- r/notebooklm ...'.
+ """
+ resolved = await context.resolve(workspace)
+ await client.request(
+ "POST",
+ "/documents",
+ json=build_note_document(
+ workspace_id=resolved.id,
+ title=title,
+ content=content,
+ source_url=source_url,
+ ),
+ )
+ return (
+ f"Queued '{title}' for indexing in '{resolved.name}'. "
+ "It will be searchable once processing completes."
+ )
+
+ @mcp.tool(
+ name="surfsense_upload_file",
+ title="Upload a file",
+ annotations=_WRITE,
+ structured_output=False,
+ )
+ async def upload_file(
+ file_path: Annotated[
+ str,
+ Field(
+ description="Path to a local file, e.g. "
+ "'C:/Users/me/report.pdf' or '~/notes/summary.md'."
+ ),
+ ],
+ use_vision_llm: Annotated[
+ bool,
+ Field(
+ description="True reads scanned or image-heavy files with a "
+ "vision model (slower)."
+ ),
+ ] = False,
+ workspace: WorkspaceParam = None,
+ ) -> str:
+ """Upload a local file (PDF, docx, markdown, etc.) into the knowledge base.
+
+ Use this to ingest a file from disk so its content becomes searchable;
+ for text you already have in hand use surfsense_add_document instead.
+ The file is parsed, chunked, and indexed asynchronously. Duplicate
+ files are detected and skipped.
+ Example: file_path='C:/Users/me/report.pdf'.
+ """
+ resolved = await context.resolve(workspace)
+ payload = _read_upload(file_path)
+ result = await client.request(
+ "POST",
+ "/documents/fileupload",
+ data={
+ "workspace_id": str(resolved.id),
+ "use_vision_llm": str(use_vision_llm).lower(),
+ "processing_mode": "basic",
+ },
+ files=[("files", payload)],
+ )
+ pending = (result or {}).get("pending_files", 0)
+ skipped = (result or {}).get("skipped_duplicates", 0)
+ note = " (already present, skipped)" if skipped and not pending else ""
+ return (
+ f"Uploaded '{Path(file_path).name}' to '{resolved.name}'{note}. "
+ "It will be searchable once processing completes."
+ )
+
+ @mcp.tool(
+ name="surfsense_update_document",
+ title="Replace a document's content",
+ annotations=_WRITE,
+ structured_output=False,
+ )
+ async def update_document(
+ document_id: _DOCUMENT_ID,
+ content: Annotated[
+ str,
+ Field(
+ min_length=1,
+ description="New full text; replaces the existing content "
+ "entirely.",
+ ),
+ ],
+ ) -> str:
+ """Replace a document's stored content by id.
+
+ Use this to correct or rewrite a document's text. The new content
+ REPLACES the old entirely — to append, read the document first with
+ surfsense_get_document and resend the combined text. Search chunks are
+ not re-indexed by this call.
+ """
+ existing = await client.request("GET", f"/documents/{document_id}")
+ await client.request(
+ "PUT",
+ f"/documents/{document_id}",
+ json={
+ "document_type": existing["document_type"],
+ "workspace_id": existing["workspace_id"],
+ "content": content,
+ },
+ )
+ return f"Updated document {document_id} ('{existing.get('title', '')}')."
+
+ @mcp.tool(
+ name="surfsense_delete_document",
+ title="Delete a document",
+ annotations=_DELETE,
+ structured_output=False,
+ )
+ async def delete_document(document_id: _DOCUMENT_ID) -> str:
+ """Permanently delete a document from the knowledge base by id.
+
+ Use this only when the user explicitly asks to remove a document —
+ deletion cannot be undone. The document stops appearing in searches
+ immediately.
+ """
+ await client.request("DELETE", f"/documents/{document_id}")
+ return f"Deleted document {document_id}."
+
+
+def _read_upload(file_path: str) -> tuple[str, bytes, str]:
+ path = Path(file_path).expanduser()
+ if not path.is_file():
+ raise ToolError(f"No file at '{file_path}'.")
+ mime, _ = mimetypes.guess_type(path.name)
+ return (path.name, path.read_bytes(), mime or "application/octet-stream")
+
+
+def _join(values: list[str] | None) -> str | None:
+ return ",".join(values) if values else None
+
+
+def _render_search(query: str, items: list[dict]) -> str:
+ if not items:
+ return f'No matches for "{query}".'
+ lines = [f'# {len(items)} result(s) for "{query}"', ""]
+ for hit in items:
+ lines.append(
+ f"## {hit.get('title', 'Untitled')} "
+ f"(id {hit.get('document_id')}) — score {hit.get('score', 0):.3f}"
+ )
+ for chunk in hit.get("chunks", []):
+ excerpt = clip(chunk.get("content", "").strip(), 500)
+ lines.append(f"> {excerpt}")
+ lines.append("")
+ return "\n".join(lines).strip()
+
+
+def _render_document_list(result: dict | None) -> str:
+ items = (result or {}).get("items", [])
+ if not items:
+ return "No documents found."
+ lines = ["# Documents", ""]
+ for doc in items:
+ lines.append(
+ f"- **{doc.get('title', 'Untitled')}** (id {doc.get('id')}) · "
+ f"{doc.get('document_type')} · updated {doc.get('updated_at')}"
+ )
+ total = (result or {}).get("total", len(items))
+ page = (result or {}).get("page", 0)
+ has_more = (result or {}).get("has_more", False)
+ lines.append("")
+ lines.append(
+ f"_Page {page} · showing {len(items)} of {total}"
+ + (" · more available_" if has_more else "_")
+ )
+ return "\n".join(lines)
+
+
+def _render_document(document: dict) -> str:
+ content = clip(document.get("content", "") or "(empty)")
+ return (
+ f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n"
+ f"- type: {document.get('document_type')}\n"
+ f"- workspace: {document.get('workspace_id')}\n"
+ f"- updated: {document.get('updated_at')}\n\n"
+ f"{content}"
+ )
diff --git a/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/note_ingestion.py b/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/note_ingestion.py
new file mode 100644
index 000000000..a02406c62
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/features/knowledge_base/note_ingestion.py
@@ -0,0 +1,45 @@
+"""Translate a plain note into SurfSense's document-ingestion envelope.
+
+The REST API ingests free text through the browser-extension document shape
+(title + page content + visit metadata); the backend then chunks and embeds it
+like any saved page. Isolating that mapping lets the KB tool offer a simple
+title+content surface without leaking the envelope's shape.
+"""
+
+from __future__ import annotations
+
+import re
+from datetime import datetime, timezone
+
+
+def build_note_document(
+ *, workspace_id: int, title: str, content: str, source_url: str | None
+) -> dict:
+ """Wrap a note in the EXTENSION document payload the create endpoint expects."""
+ # ponytail: reuses the extension-ingestion path to add free text. Ceiling —
+ # visit metadata is synthesized; the "real page URL" is a stable synthetic
+ # link derived from the title. Upgrade path: a first-class note endpoint.
+ captured_at = datetime.now(timezone.utc).isoformat()
+ return {
+ "document_type": "EXTENSION",
+ "workspace_id": workspace_id,
+ "content": [
+ {
+ "metadata": {
+ "BrowsingSessionId": "surfsense-mcp",
+ "VisitedWebPageURL": source_url or _synthetic_url(title),
+ "VisitedWebPageTitle": title,
+ "VisitedWebPageDateWithTimeInISOString": captured_at,
+ "VisitedWebPageReffererURL": "",
+ "VisitedWebPageVisitDurationInMilliseconds": "0",
+ },
+ "pageContent": content,
+ }
+ ],
+ }
+
+
+def _synthetic_url(title: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "note"
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
+ return f"https://surfsense.local/mcp-note/{slug}-{stamp}"
diff --git a/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py b/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py
new file mode 100644
index 000000000..aa8265148
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/features/scrapers/__init__.py
@@ -0,0 +1,570 @@
+"""Scraper tools: one MCP surface per SurfSense platform capability.
+
+Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
+maps a natural-language request to the workspace's scraper door. Two more tools
+list and fetch past runs, so a large result truncated inline can be retrieved in
+full later.
+"""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from mcp.server.fastmcp import FastMCP
+from mcp.types import ToolAnnotations
+from pydantic import Field
+
+from ...core.client import SurfSenseClient
+from ...core.rendering import ResponseFormatParam, clip, to_json
+from ...core.workspace_context import WorkspaceContext, WorkspaceParam
+from .capability import run_scraper
+
+# Scrapers reach the open web and record a billable run; they are neither
+# read-only nor idempotent, but they do not mutate the knowledge base.
+_SCRAPE = ToolAnnotations(
+ readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True
+)
+_READ_RUNS = ToolAnnotations(
+ readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
+)
+
+RedditSort = Literal["relevance", "hot", "top", "new", "rising", "comments"]
+RedditTime = Literal["hour", "day", "week", "month", "year", "all"]
+CommentSort = Literal["TOP_COMMENTS", "NEWEST_FIRST"]
+ReviewSort = Literal["newest", "mostRelevant", "highestRanking", "lowestRanking"]
+
+
+def register(
+ mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext
+) -> None:
+ """Register the scraper and run-history tools on the server."""
+
+ @mcp.tool(
+ name="surfsense_web_crawl",
+ title="Crawl web pages",
+ annotations=_SCRAPE,
+ structured_output=False,
+ )
+ async def web_crawl(
+ start_urls: Annotated[
+ list[str],
+ Field(
+ min_length=1,
+ description="Full URLs to fetch, e.g. "
+ "['https://example.com/blog/post'].",
+ ),
+ ],
+ max_crawl_depth: Annotated[
+ int,
+ Field(
+ ge=0,
+ description="Link-hops to follow from start_urls within the "
+ "same site. 0 fetches only start_urls.",
+ ),
+ ] = 0,
+ max_crawl_pages: Annotated[
+ int, Field(ge=1, description="Stop after this many pages in total.")
+ ] = 10,
+ max_length: Annotated[
+ int, Field(ge=1, description="Max characters kept per page.")
+ ] = 50_000,
+ include_url_patterns: Annotated[
+ list[str] | None,
+ Field(
+ description="Regexes; only discovered links matching one are "
+ "followed, e.g. ['/docs/.*']."
+ ),
+ ] = None,
+ exclude_url_patterns: Annotated[
+ list[str] | None,
+ Field(description="Regexes; discovered links matching one are skipped."),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch specific web pages and return their cleaned content as markdown.
+
+ Use this to read a page the user names, or to spider a site from a
+ starting URL. Do NOT use it to find pages on a topic — use
+ surfsense_google_search for discovery. Returns one item per crawled
+ page: url, title, and the page text as markdown.
+ Example: start_urls=['https://blog.example.com'], max_crawl_depth=1,
+ include_url_patterns=['/2026/'].
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="web",
+ verb="crawl",
+ payload={
+ "startUrls": start_urls,
+ "maxCrawlDepth": max_crawl_depth,
+ "maxCrawlPages": max_crawl_pages,
+ "maxLength": max_length,
+ "includeUrlPatterns": include_url_patterns,
+ "excludeUrlPatterns": exclude_url_patterns,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_google_search",
+ title="Scrape Google Search",
+ annotations=_SCRAPE,
+ structured_output=False,
+ )
+ async def google_search(
+ queries: Annotated[
+ list[str],
+ Field(
+ min_length=1,
+ description="Search terms or full Google Search URLs, e.g. "
+ "['best rss readers 2026'].",
+ ),
+ ],
+ max_pages_per_query: Annotated[
+ int, Field(ge=1, description="Result pages to fetch per query.")
+ ] = 1,
+ country_code: Annotated[
+ str | None,
+ Field(description="Two-letter country to search from, e.g. 'us'."),
+ ] = None,
+ language_code: Annotated[
+ str, Field(description="Results language, e.g. 'en'. Empty for default.")
+ ] = "",
+ site: Annotated[
+ str | None,
+ Field(
+ description="Restrict results to one domain, e.g. 'example.com'."
+ ),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Scrape Google Search result pages for one or more queries.
+
+ Use this to discover pages on the open web by topic; follow up with
+ surfsense_web_crawl to read a result in full. Do NOT use it for
+ Reddit, YouTube, or Google Maps research — the dedicated tools return
+ richer data. Returns each query's parsed results: title, url, and
+ snippet per organic result.
+ Example: queries=['notebooklm review'], site='news.ycombinator.com'.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="google_search",
+ verb="scrape",
+ payload={
+ "queries": queries,
+ "max_pages_per_query": max_pages_per_query,
+ "country_code": country_code,
+ "language_code": language_code,
+ "site": site,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_reddit_scrape",
+ title="Search or scrape Reddit",
+ annotations=_SCRAPE,
+ structured_output=False,
+ )
+ async def reddit_scrape(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="Reddit URLs: a post, a subreddit like "
+ "'https://reddit.com/r/LocalLLaMA', a user page, or a search "
+ "URL. Provide urls OR search_queries."
+ ),
+ ] = None,
+ search_queries: Annotated[
+ list[str] | None,
+ Field(
+ description="Terms to search Reddit for, e.g. "
+ "['NotebookLM alternatives']. Provide search_queries OR urls."
+ ),
+ ] = None,
+ community: Annotated[
+ str | None,
+ Field(
+ description="Restrict a search to one subreddit, name without "
+ "'r/', e.g. 'ArtificialInteligence'."
+ ),
+ ] = None,
+ sort: Annotated[RedditSort, Field(description="Post ordering.")] = "new",
+ time_filter: Annotated[
+ RedditTime | None,
+ Field(description="Time window; only valid with sort='top'."),
+ ] = None,
+ max_items: Annotated[
+ int, Field(ge=1, description="Maximum posts to return.")
+ ] = 10,
+ skip_comments: Annotated[
+ bool,
+ Field(
+ description="True fetches posts only (faster); False also "
+ "fetches each post's comment thread."
+ ),
+ ] = False,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Search or scrape Reddit: posts, comments, subreddits, and users.
+
+ Use this for ANY Reddit research — finding relevant subreddits or
+ communities for a topic, top posts, or discussions — instead of a
+ generic web search. Returns posts (title, text, score, subreddit, url)
+ with comment threads unless skip_comments is set. Every post carries
+ its subreddit, so to find communities for a topic, search posts and
+ aggregate their subreddits.
+ Example: search_queries=['NotebookLM'], sort='top', time_filter='month'.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="reddit",
+ verb="scrape",
+ payload={
+ "urls": urls,
+ "search_queries": search_queries,
+ "community": community,
+ "sort": sort,
+ "time_filter": time_filter,
+ "max_items": max_items,
+ "skip_comments": skip_comments,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_youtube_scrape",
+ title="Search or scrape YouTube",
+ annotations=_SCRAPE,
+ structured_output=False,
+ )
+ async def youtube_scrape(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="YouTube URLs: video, channel, playlist, shorts, "
+ "or hashtag pages. Provide urls OR search_queries."
+ ),
+ ] = None,
+ search_queries: Annotated[
+ list[str] | None,
+ Field(
+ description="Terms to search YouTube for, e.g. "
+ "['NotebookLM tutorial']. Provide search_queries OR urls."
+ ),
+ ] = None,
+ max_results: Annotated[
+ int, Field(ge=1, description="Maximum videos to return.")
+ ] = 10,
+ download_subtitles: Annotated[
+ bool,
+ Field(description="True also fetches each video's transcript."),
+ ] = False,
+ subtitles_language: Annotated[
+ str, Field(description="Transcript language code, e.g. 'en'.")
+ ] = "en",
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Search or scrape YouTube videos, optionally with transcripts.
+
+ Use this for YouTube research: finding videos on a topic, or reading a
+ video's details or transcript. For a video's comment section use
+ surfsense_youtube_comments instead. Returns per-video metadata (title,
+ channel, views, description, url) and, if requested, the transcript.
+ Example: search_queries=['NotebookLM tutorial'], download_subtitles=True.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="youtube",
+ verb="scrape",
+ payload={
+ "urls": urls,
+ "search_queries": search_queries,
+ "max_results": max_results,
+ "download_subtitles": download_subtitles,
+ "subtitles_language": subtitles_language,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_youtube_comments",
+ title="Fetch YouTube comments",
+ annotations=_SCRAPE,
+ structured_output=False,
+ )
+ async def youtube_comments(
+ urls: Annotated[
+ list[str],
+ Field(
+ min_length=1,
+ description="YouTube video URLs, e.g. "
+ "['https://www.youtube.com/watch?v=abc123'].",
+ ),
+ ],
+ max_comments: Annotated[
+ int,
+ Field(
+ ge=1,
+ description="Maximum comments per video, counting top-level "
+ "comments and replies together.",
+ ),
+ ] = 20,
+ sort_by: Annotated[
+ CommentSort, Field(description="Comment ordering.")
+ ] = "NEWEST_FIRST",
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch the comments (and replies) on one or more YouTube videos.
+
+ Use this when the user wants a video's discussion or audience reaction
+ rather than the video itself; get video URLs from
+ surfsense_youtube_scrape if you only have a topic. Returns comment
+ text, author, likes, and replies.
+ Example: urls=['https://www.youtube.com/watch?v=abc123'], max_comments=50.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="youtube",
+ verb="comments",
+ payload={
+ "urls": urls,
+ "max_comments": max_comments,
+ "sort_by": sort_by,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_google_maps_scrape",
+ title="Find places on Google Maps",
+ annotations=_SCRAPE,
+ structured_output=False,
+ )
+ async def google_maps_scrape(
+ search_queries: Annotated[
+ list[str] | None,
+ Field(
+ description="Place searches, e.g. ['coffee shops']. Provide "
+ "search_queries OR urls OR place_ids."
+ ),
+ ] = None,
+ urls: Annotated[
+ list[str] | None,
+ Field(description="Google Maps URLs of specific places."),
+ ] = None,
+ place_ids: Annotated[
+ list[str] | None,
+ Field(description="Google place ids, e.g. ['ChIJj61dQgK6j4AR...']."),
+ ] = None,
+ location: Annotated[
+ str | None,
+ Field(
+ description="Geographic scope for a search, e.g. "
+ "'Seattle, USA'."
+ ),
+ ] = None,
+ max_places: Annotated[
+ int, Field(ge=1, description="Maximum places to return.")
+ ] = 10,
+ include_details: Annotated[
+ bool,
+ Field(
+ description="True adds opening hours and extra contact info "
+ "(slower)."
+ ),
+ ] = False,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Find places on Google Maps by search, URL, or place id.
+
+ Use this for local-business and location research: names, addresses,
+ ratings, categories, coordinates, place ids. For a place's customer
+ reviews use surfsense_google_maps_reviews instead.
+ Example: search_queries=['ramen'], location='Osaka, Japan', max_places=5.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="google_maps",
+ verb="scrape",
+ payload={
+ "search_queries": search_queries,
+ "urls": urls,
+ "place_ids": place_ids,
+ "location": location,
+ "max_places": max_places,
+ "include_details": include_details,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_google_maps_reviews",
+ title="Fetch Google Maps reviews",
+ annotations=_SCRAPE,
+ structured_output=False,
+ )
+ async def google_maps_reviews(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="Google Maps URLs of places. Provide urls OR "
+ "place_ids."
+ ),
+ ] = None,
+ place_ids: Annotated[
+ list[str] | None,
+ Field(
+ description="Google place ids from surfsense_google_maps_scrape."
+ ),
+ ] = None,
+ max_reviews: Annotated[
+ int, Field(ge=1, description="Maximum reviews per place.")
+ ] = 20,
+ sort_by: Annotated[
+ ReviewSort, Field(description="Review ordering.")
+ ] = "newest",
+ language: Annotated[
+ str, Field(description="Reviews language code, e.g. 'en'.")
+ ] = "en",
+ start_date: Annotated[
+ str | None,
+ Field(
+ description="ISO date like '2026-01-01'; keeps only reviews on "
+ "or after that day."
+ ),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch customer reviews for Google Maps places by URL or place id.
+
+ Use this to read feedback on specific places; get urls or place_ids
+ from surfsense_google_maps_scrape first if you only have a name.
+ Returns review text, rating, author, and date per review.
+ Example: place_ids=['ChIJj61dQgK6j4AR...'], sort_by='newest'.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="google_maps",
+ verb="reviews",
+ payload={
+ "urls": urls,
+ "place_ids": place_ids,
+ "max_reviews": max_reviews,
+ "sort_by": sort_by,
+ "language": language,
+ "start_date": start_date,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_list_scraper_runs",
+ title="List past scraper runs",
+ annotations=_READ_RUNS,
+ structured_output=False,
+ )
+ async def list_scraper_runs(
+ limit: Annotated[
+ int, Field(ge=1, description="Maximum runs to list.")
+ ] = 20,
+ capability: Annotated[
+ str | None,
+ Field(
+ description="Filter by capability slug, e.g. 'web.crawl' or "
+ "'reddit.scrape'."
+ ),
+ ] = None,
+ status: Annotated[
+ str | None,
+ Field(description="Filter by run status: 'success' or 'error'."),
+ ] = None,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """List recent scraper runs in the workspace, newest first.
+
+ Use this to find the run_id of an earlier scrape — for example when an
+ inline result was truncated — then fetch it in full with
+ surfsense_get_scraper_run. Returns each run's id, capability, status,
+ item count, and creation time.
+ Example: capability='reddit.scrape', status='success'.
+ """
+ resolved = await context.resolve(workspace)
+ runs = await client.request(
+ "GET",
+ f"/workspaces/{resolved.id}/scrapers/runs",
+ params={
+ "limit": limit,
+ "capability": capability,
+ "status": status,
+ },
+ )
+ if response_format == "json":
+ return to_json(runs)
+ return _render_runs(runs)
+
+ @mcp.tool(
+ name="surfsense_get_scraper_run",
+ title="Fetch one scraper run in full",
+ annotations=_READ_RUNS,
+ structured_output=False,
+ )
+ async def get_scraper_run(
+ run_id: Annotated[
+ str,
+ Field(
+ description="Run id from surfsense_list_scraper_runs or a "
+ "prior scrape's output."
+ ),
+ ],
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch a single scraper run in full, including its stored output.
+
+ Use this to retrieve the complete, untruncated result of an earlier
+ scrape. Do NOT re-run a scraper just to recover a truncated result —
+ fetch the stored run instead.
+ """
+ resolved = await context.resolve(workspace)
+ run = await client.request(
+ "GET", f"/workspaces/{resolved.id}/scrapers/runs/{run_id}"
+ )
+ if response_format == "json":
+ return clip(to_json(run))
+ return f"# Run {run.get('id', run_id)}\n\n```json\n{clip(to_json(run))}\n```"
+
+
+def _render_runs(runs: list[dict] | None) -> str:
+ if not runs:
+ return "No scraper runs found."
+ lines = ["# Scraper runs", ""]
+ for run in runs:
+ lines.append(
+ f"- **{run.get('id')}** — {run.get('capability')} · {run.get('status')} · "
+ f"{run.get('item_count', 0)} item(s) · {run.get('created_at')}"
+ )
+ return "\n".join(lines)
diff --git a/surfsense_mcp/src/surfsense_mcp/features/scrapers/capability.py b/surfsense_mcp/src/surfsense_mcp/features/scrapers/capability.py
new file mode 100644
index 000000000..7245c9f84
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/features/scrapers/capability.py
@@ -0,0 +1,57 @@
+"""Run a SurfSense scraper capability and shape its result.
+
+Shared by every platform tool: POST a typed payload to the workspace's scraper
+door and render the returned items as markdown or JSON.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ...core.client import SurfSenseClient
+from ...core.rendering import ResponseFormat, clip, compact_items, to_json
+from ...core.workspace_context import WorkspaceContext
+
+
+async def run_scraper(
+ client: SurfSenseClient,
+ context: WorkspaceContext,
+ *,
+ platform: str,
+ verb: str,
+ payload: dict[str, Any],
+ workspace: str | None,
+ response_format: ResponseFormat,
+) -> str:
+ """Execute one scraper verb for the resolved workspace and render its output."""
+ resolved = await context.resolve(workspace)
+ body = {key: value for key, value in payload.items() if value is not None}
+ result = await client.request(
+ "POST", f"/workspaces/{resolved.id}/scrapers/{platform}/{verb}", json=body
+ )
+ # Inline results are compacted (redundant fields dropped, long fields
+ # excerpted) so every item survives the overall clip; the complete output
+ # is stored server-side and retrievable with surfsense_get_scraper_run.
+ result = compact_items(result)
+ if response_format == "json":
+ return clip(to_json(result))
+ return _render_markdown(platform, verb, resolved.name, result)
+
+
+def _render_markdown(
+ platform: str, verb: str, workspace_name: str, result: Any
+) -> str:
+ """A readable header plus the structured payload, clipped to a safe size."""
+ header = f'# {platform}.{verb} — {_describe_size(result)} from "{workspace_name}"'
+ body = clip(to_json(result))
+ footer = (
+ "\n\nFields shown as excerpts; use surfsense_get_scraper_run for the "
+ "full output."
+ )
+ return f"{header}\n\n```json\n{body}\n```{footer}"
+
+
+def _describe_size(result: Any) -> str:
+ if isinstance(result, dict) and isinstance(result.get("items"), list):
+ return f"{len(result['items'])} item(s)"
+ return "result"
diff --git a/surfsense_mcp/src/surfsense_mcp/features/workspaces/__init__.py b/surfsense_mcp/src/surfsense_mcp/features/workspaces/__init__.py
new file mode 100644
index 000000000..6daef8a3c
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/features/workspaces/__init__.py
@@ -0,0 +1,100 @@
+"""Search-space selector: discover workspaces and choose the active one.
+
+A workspace (the product calls it a "search space") scopes every other tool.
+These two tools let a client list what's available and pick one by name, so the
+rest of the conversation needs no ids.
+"""
+
+from __future__ import annotations
+
+from typing import Annotated
+
+from mcp.server.fastmcp import FastMCP
+from mcp.types import ToolAnnotations
+from pydantic import Field
+
+from ...core.rendering import ResponseFormatParam, to_json
+from ...core.workspace_context import Workspace, WorkspaceContext
+
+_READ_ONLY = ToolAnnotations(
+ readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
+)
+
+
+def register(mcp: FastMCP, context: WorkspaceContext) -> None:
+ """Register the workspace selector tools on the server."""
+
+ @mcp.tool(
+ name="surfsense_list_workspaces",
+ title="List workspaces",
+ annotations=_READ_ONLY,
+ structured_output=False,
+ )
+ async def list_workspaces(
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """List the SurfSense workspaces (search spaces) the account can access.
+
+ Use this to discover which workspaces exist before selecting one, or
+ when the user asks what search spaces they have. Returns each
+ workspace's name, id, description, ownership, and member count, and
+ marks the currently active one.
+ """
+ workspaces = await context.fetch_all()
+ if response_format == "json":
+ return to_json([_as_dict(w) for w in workspaces])
+ return _render_list(workspaces, active=context.active)
+
+ @mcp.tool(
+ name="surfsense_select_workspace",
+ title="Select active workspace",
+ annotations=_READ_ONLY,
+ structured_output=False,
+ )
+ async def select_workspace(
+ workspace: Annotated[
+ str,
+ Field(
+ description="Workspace name or numeric id; matching is "
+ "case-insensitive and a unique partial name works. "
+ "Example: 'Research'."
+ ),
+ ],
+ ) -> str:
+ """Set the active workspace (search space) that later tools default to.
+
+ Use this when the user says which search space to work in ("use my
+ Research space"), or after surfsense_list_workspaces when several
+ exist. Once set, workspace-scoped tools use it unless given a
+ different 'workspace'. Do NOT call it before every tool — once per
+ session is enough.
+ """
+ selected = await context.resolve(workspace)
+ return (
+ f"Active workspace is now '{selected.name}' (id {selected.id}). "
+ "Other tools will use it unless you pass a different 'workspace'."
+ )
+
+
+def _render_list(workspaces: list[Workspace], *, active: Workspace | None) -> str:
+ if not workspaces:
+ return "No accessible workspaces."
+ lines = ["# Workspaces", ""]
+ for workspace in workspaces:
+ marker = " — active" if active and active.id == workspace.id else ""
+ role = "owner" if workspace.is_owner else "member"
+ lines.append(f"- **{workspace.name}** (id {workspace.id}){marker}")
+ if workspace.description:
+ lines.append(f" - {workspace.description}")
+ lines.append(f" - {role}, {workspace.member_count} member(s)")
+ return "\n".join(lines)
+
+
+def _as_dict(workspace: Workspace) -> dict:
+ return {
+ "id": workspace.id,
+ "name": workspace.name,
+ "description": workspace.description,
+ "is_owner": workspace.is_owner,
+ "member_count": workspace.member_count,
+ }
diff --git a/surfsense_mcp/src/surfsense_mcp/selfcheck.py b/surfsense_mcp/src/surfsense_mcp/selfcheck.py
new file mode 100644
index 000000000..e38edc909
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/selfcheck.py
@@ -0,0 +1,93 @@
+"""Offline smoke check: every tool registers with a usable name, doc, and schema.
+
+Runs without a backend or network — it only assembles the server and inspects
+the tool manifest the client would see. Fails loudly if a tool is missing, its
+description is too thin to route on, or its input schema is malformed.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+
+from .config import Settings
+from .server import build_server
+
+EXPECTED_TOOLS = {
+ # search-space selector
+ "surfsense_list_workspaces",
+ "surfsense_select_workspace",
+ # scrapers (all platforms) + run history
+ "surfsense_web_crawl",
+ "surfsense_google_search",
+ "surfsense_reddit_scrape",
+ "surfsense_youtube_scrape",
+ "surfsense_youtube_comments",
+ "surfsense_google_maps_scrape",
+ "surfsense_google_maps_reviews",
+ "surfsense_list_scraper_runs",
+ "surfsense_get_scraper_run",
+ # knowledge-base management
+ "surfsense_search_knowledge_base",
+ "surfsense_list_documents",
+ "surfsense_get_document",
+ "surfsense_add_document",
+ "surfsense_upload_file",
+ "surfsense_update_document",
+ "surfsense_delete_document",
+}
+
+_MIN_DESCRIPTION_CHARS = 40
+
+
+async def _collect_tools() -> dict[str, object]:
+ settings = Settings(
+ base_url="http://localhost:8000",
+ api_key="ss_pat_selfcheck",
+ api_prefix="/api/v1",
+ timeout=5.0,
+ default_workspace=None,
+ )
+ mcp, _client = build_server(settings)
+ tools = await mcp.list_tools()
+ return {tool.name: tool for tool in tools}
+
+
+def run() -> list[str]:
+ """Return a list of problems; empty means the manifest is healthy."""
+ tools = asyncio.run(_collect_tools())
+ problems: list[str] = []
+
+ missing = EXPECTED_TOOLS - tools.keys()
+ if missing:
+ problems.append(f"missing tools: {sorted(missing)}")
+ unexpected = tools.keys() - EXPECTED_TOOLS
+ if unexpected:
+ problems.append(f"unexpected tools: {sorted(unexpected)}")
+
+ for name, tool in tools.items():
+ description = tool.description or ""
+ if len(description) < _MIN_DESCRIPTION_CHARS:
+ problems.append(f"{name}: description too short to route on")
+ schema = tool.inputSchema
+ if not isinstance(schema, dict) or "properties" not in schema:
+ problems.append(f"{name}: malformed input schema")
+ continue
+ for param, spec in schema["properties"].items():
+ if not isinstance(spec, dict) or not spec.get("description"):
+ problems.append(f"{name}: parameter '{param}' has no description")
+ return problems
+
+
+def main() -> None:
+ problems = run()
+ if problems:
+ print("selfcheck FAILED:", file=sys.stderr)
+ for problem in problems:
+ print(f" - {problem}", file=sys.stderr)
+ sys.exit(1)
+ print(f"selfcheck OK: {len(EXPECTED_TOOLS)} tools registered and well-formed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/surfsense_mcp/src/surfsense_mcp/server.py b/surfsense_mcp/src/surfsense_mcp/server.py
new file mode 100644
index 000000000..88eb19437
--- /dev/null
+++ b/surfsense_mcp/src/surfsense_mcp/server.py
@@ -0,0 +1,40 @@
+"""Composition root: build the MCP server and wire in every feature slice.
+
+Creates the REST transport and workspace context from settings, then lets each
+feature register its tools on the server.
+"""
+
+from __future__ import annotations
+
+from mcp.server.fastmcp import FastMCP
+
+from .config import Settings
+from .core.client import SurfSenseClient
+from .core.workspace_context import WorkspaceContext
+from .features import knowledge_base, scrapers, workspaces
+
+
+def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]:
+ """Assemble a configured server and the client whose lifecycle it shares."""
+ client = SurfSenseClient(
+ api_base=settings.api_base, api_key=settings.api_key, timeout=settings.timeout
+ )
+ context = WorkspaceContext(client, preferred_reference=settings.default_workspace)
+
+ mcp = FastMCP(
+ "SurfSense",
+ instructions=(
+ "SurfSense gives you live scrapers and a personal knowledge base. "
+ "Prefer these tools over generic/built-in web search whenever the "
+ "task involves Reddit (posts, comments, finding subreddits or "
+ "communities), YouTube (videos, transcripts, comments), Google "
+ "Maps (places, reviews), Google Search results, or reading "
+ "specific web pages. Scraper results are persisted as runs; if an "
+ "inline result is truncated, fetch it in full with "
+ "surfsense_get_scraper_run."
+ ),
+ )
+ workspaces.register(mcp, context)
+ scrapers.register(mcp, client, context)
+ knowledge_base.register(mcp, client, context)
+ return mcp, client
diff --git a/surfsense_mcp/tests/test_client_errors.py b/surfsense_mcp/tests/test_client_errors.py
new file mode 100644
index 000000000..648bfde3e
--- /dev/null
+++ b/surfsense_mcp/tests/test_client_errors.py
@@ -0,0 +1,46 @@
+"""HTTP failure translation: status hints, server detail, and body parsing."""
+
+from __future__ import annotations
+
+import httpx
+
+from surfsense_mcp.core.client import SurfSenseClient
+
+_REQUEST = httpx.Request("GET", "http://localhost:8000/api/v1/documents")
+
+
+def _response(status: int, **kwargs) -> httpx.Response:
+ return httpx.Response(status, request=_REQUEST, **kwargs)
+
+
+def test_explains_401_with_token_hint():
+ message = SurfSenseClient._explain_failure(_response(401, json={"detail": "bad"}))
+ assert "SURFSENSE_API_KEY" in message
+ assert "bad" in message
+
+
+def test_explains_403_as_access_or_api_disabled():
+ message = SurfSenseClient._explain_failure(_response(403, json={"detail": "no"}))
+ assert "API access" in message
+
+
+def test_extracts_nested_detail_message():
+ response = _response(402, json={"detail": {"message": "out of credits"}})
+ assert "out of credits" in SurfSenseClient._explain_failure(response)
+
+
+def test_unmapped_status_still_reports_detail():
+ message = SurfSenseClient._explain_failure(_response(500, json={"detail": "boom"}))
+ assert "500" in message and "boom" in message
+
+
+def test_parses_json_body():
+ assert SurfSenseClient._parse_body(_response(200, json={"ok": 1})) == {"ok": 1}
+
+
+def test_empty_body_parses_to_none():
+ assert SurfSenseClient._parse_body(_response(204, content=b"")) is None
+
+
+def test_non_json_body_falls_back_to_text():
+ assert SurfSenseClient._parse_body(_response(200, text="hello")) == "hello"
diff --git a/surfsense_mcp/tests/test_client_params.py b/surfsense_mcp/tests/test_client_params.py
new file mode 100644
index 000000000..4840ad488
--- /dev/null
+++ b/surfsense_mcp/tests/test_client_params.py
@@ -0,0 +1,38 @@
+"""Unset query params must be omitted, not sent as empty strings."""
+
+from __future__ import annotations
+
+import asyncio
+
+import httpx
+
+from surfsense_mcp.core.client import SurfSenseClient
+
+
+def _capture(client: SurfSenseClient) -> dict:
+ """Swap in a mock transport that records the request it receives."""
+ seen: dict = {}
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ seen["params"] = dict(request.url.params)
+ return httpx.Response(200, json={"ok": True})
+
+ client._http = httpx.AsyncClient(
+ base_url="http://test/api/v1", transport=httpx.MockTransport(handler)
+ )
+ return seen
+
+
+def test_none_params_are_dropped():
+ client = SurfSenseClient(api_base="http://test/api/v1", api_key="ss_pat_x", timeout=5)
+ seen = _capture(client)
+ asyncio.run(
+ client.request(
+ "GET",
+ "/documents",
+ params={"workspace_id": 1, "document_types": None, "folder_id": None},
+ )
+ )
+ assert seen["params"] == {"workspace_id": "1"}
+ assert "folder_id" not in seen["url"]
diff --git a/surfsense_mcp/tests/test_note_ingestion.py b/surfsense_mcp/tests/test_note_ingestion.py
new file mode 100644
index 000000000..6ab16acfc
--- /dev/null
+++ b/surfsense_mcp/tests/test_note_ingestion.py
@@ -0,0 +1,31 @@
+"""The note-to-document envelope mapping."""
+
+from __future__ import annotations
+
+from surfsense_mcp.features.knowledge_base.note_ingestion import build_note_document
+
+
+def test_builds_extension_document_with_content():
+ doc = build_note_document(
+ workspace_id=3, title="Meeting notes", content="body", source_url=None
+ )
+ assert doc["document_type"] == "EXTENSION"
+ assert doc["workspace_id"] == 3
+ entry = doc["content"][0]
+ assert entry["pageContent"] == "body"
+ assert entry["metadata"]["VisitedWebPageTitle"] == "Meeting notes"
+
+
+def test_synthesizes_url_when_none_given():
+ doc = build_note_document(
+ workspace_id=1, title="Q3 Plan!", content="x", source_url=None
+ )
+ url = doc["content"][0]["metadata"]["VisitedWebPageURL"]
+ assert url.startswith("https://surfsense.local/mcp-note/q3-plan-")
+
+
+def test_keeps_provided_source_url():
+ doc = build_note_document(
+ workspace_id=1, title="t", content="x", source_url="https://example.com/a"
+ )
+ assert doc["content"][0]["metadata"]["VisitedWebPageURL"] == "https://example.com/a"
diff --git a/surfsense_mcp/tests/test_rendering.py b/surfsense_mcp/tests/test_rendering.py
new file mode 100644
index 000000000..5b254e39a
--- /dev/null
+++ b/surfsense_mcp/tests/test_rendering.py
@@ -0,0 +1,42 @@
+"""Output shaping: clipping oversized text and JSON serialization."""
+
+from __future__ import annotations
+
+from surfsense_mcp.core.rendering import clip, compact_items, to_json
+
+
+def test_clip_leaves_short_text_untouched():
+ assert clip("short", limit=100) == "short"
+
+
+def test_clip_truncates_and_marks_dropped_characters():
+ clipped = clip("x" * 50, limit=10)
+ assert clipped.startswith("x" * 10)
+ assert "40 more characters truncated" in clipped
+
+
+def test_to_json_serializes_non_native_values():
+ from datetime import datetime
+
+ rendered = to_json({"at": datetime(2026, 1, 2, 3, 4, 5)})
+ assert "2026-01-02" in rendered
+
+
+def test_compact_items_drops_html_and_excerpts_long_fields():
+ result = {
+ "items": [
+ {"title": "t", "body": "b" * 5_000, "html": "dup
", "upVotes": 3}
+ ]
+ }
+ compacted = compact_items(result, field_limit=100)
+ item = compacted["items"][0]
+ assert "html" not in item
+ assert len(item["body"]) < 200 and "truncated" in item["body"]
+ assert item["upVotes"] == 3
+ # original untouched
+ assert "html" in result["items"][0]
+
+
+def test_compact_items_passes_through_non_item_results():
+ assert compact_items({"ok": True}) == {"ok": True}
+ assert compact_items([1, 2]) == [1, 2]
diff --git a/surfsense_mcp/tests/test_workspace_context.py b/surfsense_mcp/tests/test_workspace_context.py
new file mode 100644
index 000000000..9ba9c2ca3
--- /dev/null
+++ b/surfsense_mcp/tests/test_workspace_context.py
@@ -0,0 +1,98 @@
+"""Workspace resolution: names, ids, defaults, and the ambiguous cases."""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from surfsense_mcp.core.errors import ToolError
+from surfsense_mcp.core.workspace_context import WorkspaceContext
+
+
+class FakeClient:
+ """Stands in for SurfSenseClient, serving a fixed workspace list."""
+
+ def __init__(self, rows: list[dict]) -> None:
+ self._rows = rows
+
+ async def request(self, method: str, path: str, **_kwargs):
+ assert (method, path) == ("GET", "/workspaces")
+ return self._rows
+
+
+def _rows(*names_ids: tuple[str, int]) -> list[dict]:
+ return [{"id": wid, "name": name} for name, wid in names_ids]
+
+
+def _context(rows: list[dict], preferred: str | None = None) -> WorkspaceContext:
+ return WorkspaceContext(FakeClient(rows), preferred_reference=preferred)
+
+
+def test_resolves_exact_name():
+ ctx = _context(_rows(("Research", 1), ("Marketing", 2)))
+ assert asyncio.run(ctx.resolve("Research")).id == 1
+
+
+def test_resolves_case_insensitively():
+ ctx = _context(_rows(("Research", 1)))
+ assert asyncio.run(ctx.resolve("research")).id == 1
+
+
+def test_resolves_unique_substring():
+ ctx = _context(_rows(("Research Space", 1), ("Marketing", 2)))
+ assert asyncio.run(ctx.resolve("resea")).id == 1
+
+
+def test_ambiguous_substring_is_rejected():
+ ctx = _context(_rows(("Research A", 1), ("Research B", 2)))
+ with pytest.raises(ToolError):
+ asyncio.run(ctx.resolve("research"))
+
+
+def test_resolves_by_numeric_id():
+ ctx = _context(_rows(("Research", 1), ("Marketing", 2)))
+ assert asyncio.run(ctx.resolve(2)).name == "Marketing"
+ assert asyncio.run(ctx.resolve("2")).name == "Marketing"
+
+
+def test_unknown_id_is_rejected():
+ ctx = _context(_rows(("Research", 1)))
+ with pytest.raises(ToolError):
+ asyncio.run(ctx.resolve(99))
+
+
+def test_unknown_name_is_rejected():
+ ctx = _context(_rows(("Research", 1)))
+ with pytest.raises(ToolError):
+ asyncio.run(ctx.resolve("Nope"))
+
+
+def test_default_auto_selects_single_workspace():
+ ctx = _context(_rows(("Only", 7)))
+ assert asyncio.run(ctx.resolve(None)).id == 7
+
+
+def test_default_with_multiple_requires_a_choice():
+ ctx = _context(_rows(("A", 1), ("B", 2)))
+ with pytest.raises(ToolError):
+ asyncio.run(ctx.resolve(None))
+
+
+def test_default_with_no_workspaces_is_rejected():
+ ctx = _context([])
+ with pytest.raises(ToolError):
+ asyncio.run(ctx.resolve(None))
+
+
+def test_default_uses_preferred_reference():
+ ctx = _context(_rows(("A", 1), ("Research", 2)), preferred="Research")
+ assert asyncio.run(ctx.resolve(None)).id == 2
+
+
+def test_resolution_is_remembered_as_active():
+ ctx = _context(_rows(("A", 1), ("B", 2)))
+ asyncio.run(ctx.resolve("B"))
+ assert ctx.active is not None and ctx.active.id == 2
+ # a later default call reuses the active selection without re-choosing
+ assert asyncio.run(ctx.resolve(None)).id == 2
diff --git a/surfsense_mcp/uv.lock b/surfsense_mcp/uv.lock
new file mode 100644
index 000000000..bc97a9b2a
--- /dev/null
+++ b/surfsense_mcp/uv.lock
@@ -0,0 +1,769 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.14' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and sys_platform != 'win32'",
+ "python_full_version < '3.14' and sys_platform == 'win32'",
+ "python_full_version < '3.14' and sys_platform != 'win32'",
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.6.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "49.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
+ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
+ { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
+ { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
+ { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
+ { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
+ { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
+ { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
+ { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
+ { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
+ { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
+ { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
+ { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "httpx-sse"
+version = "0.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
+[[package]]
+name = "mcp"
+version = "1.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpx" },
+ { name = "httpx-sse" },
+ { name = "jsonschema" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "python-multipart" },
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "sse-starlette" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+ { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
+ { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
+ { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
+ { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+ { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
+ { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
+ { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
+]
+
+[package.optional-dependencies]
+crypto = [
+ { name = "cryptography" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "312"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" },
+ { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
+ { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
+ { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "2026.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" },
+ { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" },
+ { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" },
+ { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" },
+ { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
+ { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
+ { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
+ { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
+ { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
+ { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
+ { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
+ { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
+ { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
+ { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
+ { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
+ { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
+ { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
+ { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
+ { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
+ { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
+ { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
+ { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
+ { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
+ { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
+ { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
+ { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" },
+ { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" },
+ { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" },
+ { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" },
+]
+
+[[package]]
+name = "sse-starlette"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "starlette" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+]
+
+[[package]]
+name = "surfsense-mcp"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+ { name = "httpx" },
+ { name = "mcp" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "httpx", specifier = ">=0.27.0" },
+ { name = "mcp", specifier = ">=1.26.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.0" }]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.50.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/41/06cce5dbb9f77591512957710ac709e60b12e6216a2f2d0d607fd49706e8/uvicorn-0.50.0.tar.gz", hash = "sha256:0c92e1bc2259cb7faa4fcef774a5966588f2e88542744550b66799fba10b76f1", size = 93257, upload-time = "2026-07-04T05:03:26.33Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716, upload-time = "2026-07-04T05:03:24.848Z" },
+]
diff --git a/surfsense_obsidian/README.md b/surfsense_obsidian/README.md
index 6c4befcc9..52d88ab90 100644
--- a/surfsense_obsidian/README.md
+++ b/surfsense_obsidian/README.md
@@ -52,7 +52,7 @@ Open **Settings → SurfSense** in Obsidian and fill in:
| --- | --- |
| Server URL | `https://surfsense.com` for SurfSense Cloud, or your self-hosted URL |
| API token | Create a personal access token from the *Connectors → Obsidian* dialog or *User settings → API access* in the SurfSense web app |
-| Search space | Pick the search space this vault should sync into |
+| Search space | Pick the workspace this vault should sync into |
| Vault name | Defaults to your Obsidian vault name; rename if you have multiple vaults |
| Sync mode | *Auto* (recommended) or *Manual* |
| Exclude patterns | Glob patterns of folders/files to skip (e.g. `.trash`, `_attachments`, `templates/**`) |
@@ -82,7 +82,7 @@ addendum.
- `vault_id`: a random UUID minted in the plugin's `data.json` on first run
- `vault_name`: the Obsidian vault folder name
-- `search_space_id`: the SurfSense search space you picked
+- `workspace_id`: the SurfSense workspace you picked
**Sent per note on `/sync`, `/rename`, `/delete`:**
diff --git a/surfsense_obsidian/pnpm-lock.yaml b/surfsense_obsidian/pnpm-lock.yaml
new file mode 100644
index 000000000..92b269675
--- /dev/null
+++ b/surfsense_obsidian/pnpm-lock.yaml
@@ -0,0 +1,3153 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ obsidian:
+ specifier: latest
+ version: 1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6)
+ devDependencies:
+ '@eslint/js':
+ specifier: 9.30.1
+ version: 9.30.1
+ '@types/node':
+ specifier: ^20.19.39
+ version: 20.19.43
+ esbuild:
+ specifier: 0.25.5
+ version: 0.25.5
+ eslint-plugin-obsidianmd:
+ specifier: 0.1.9
+ version: 0.1.9(@eslint/js@9.30.1)(@eslint/json@0.14.0)(@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(obsidian@1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6))(typescript-eslint@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))
+ globals:
+ specifier: 14.0.0
+ version: 14.0.0
+ jiti:
+ specifier: 2.6.1
+ version: 2.6.1
+ tslib:
+ specifier: 2.4.0
+ version: 2.4.0
+ typescript:
+ specifier: ^5.8.3
+ version: 5.9.3
+ typescript-eslint:
+ specifier: 8.35.1
+ version: 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+
+packages:
+
+ '@codemirror/state@6.5.0':
+ resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==}
+
+ '@codemirror/view@6.38.6':
+ resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==}
+
+ '@esbuild/aix-ppc64@0.25.5':
+ resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.5':
+ resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.5':
+ resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.5':
+ resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.5':
+ resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.5':
+ resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.5':
+ resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.5':
+ resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.5':
+ resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.5':
+ resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.5':
+ resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.5':
+ resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.5':
+ resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.5':
+ resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.5':
+ resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.5':
+ resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.5':
+ resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.5':
+ resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.5':
+ resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.5':
+ resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.5':
+ resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/sunos-x64@0.25.5':
+ resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.5':
+ resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.5':
+ resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.5':
+ resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.21.2':
+ resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.5':
+ resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.30.1':
+ resolution: {integrity: sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.39.4':
+ resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/json@0.14.0':
+ resolution: {integrity: sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@humanfs/core@0.19.2':
+ resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.8':
+ resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/types@0.15.0':
+ resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/momoa@3.3.10':
+ resolution: {integrity: sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==}
+ engines: {node: '>=18'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@marijn/find-cluster-break@1.0.3':
+ resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
+
+ '@microsoft/eslint-plugin-sdl@1.1.0':
+ resolution: {integrity: sha512-dxdNHOemLnBhfY3eByrujX9KyLigcNtW8sU+axzWv5nLGcsSBeKW2YYyTpfPo1hV8YPOmIGnfA4fZHyKVtWqBQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ eslint: ^9
+
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
+ '@pkgr/core@0.1.2':
+ resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
+ engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+
+ '@rtsao/scc@1.1.0':
+ resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+
+ '@types/codemirror@5.60.8':
+ resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
+
+ '@types/eslint@8.56.2':
+ resolution: {integrity: sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/json5@0.0.29':
+ resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+
+ '@types/node@20.12.12':
+ resolution: {integrity: sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==}
+
+ '@types/node@20.19.43':
+ resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
+
+ '@types/tern@0.23.9':
+ resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==}
+
+ '@typescript-eslint/eslint-plugin@8.35.1':
+ resolution: {integrity: sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.35.1
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.9.0'
+
+ '@typescript-eslint/parser@8.35.1':
+ resolution: {integrity: sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.9.0'
+
+ '@typescript-eslint/project-service@8.35.1':
+ resolution: {integrity: sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <5.9.0'
+
+ '@typescript-eslint/scope-manager@8.35.1':
+ resolution: {integrity: sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.35.1':
+ resolution: {integrity: sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <5.9.0'
+
+ '@typescript-eslint/type-utils@8.35.1':
+ resolution: {integrity: sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.9.0'
+
+ '@typescript-eslint/types@8.35.1':
+ resolution: {integrity: sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.35.1':
+ resolution: {integrity: sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <5.9.0'
+
+ '@typescript-eslint/utils@8.35.1':
+ resolution: {integrity: sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.9.0'
+
+ '@typescript-eslint/visitor-keys@8.35.1':
+ resolution: {integrity: sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.17.0:
+ resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
+ ajv@8.20.0:
+ resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ array-buffer-byte-length@1.0.2:
+ resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
+ engines: {node: '>= 0.4'}
+
+ array-includes@3.1.9:
+ resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.findlast@1.2.5:
+ resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.findlastindex@1.2.6:
+ resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flat@1.3.3:
+ resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flatmap@1.3.3:
+ resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.tosorted@1.1.4:
+ resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
+ engines: {node: '>= 0.4'}
+
+ arraybuffer.prototype.slice@1.0.4:
+ resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
+ engines: {node: '>= 0.4'}
+
+ async-function@1.0.0:
+ resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
+ engines: {node: '>= 0.4'}
+
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ brace-expansion@1.1.15:
+ resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
+
+ brace-expansion@2.1.1:
+ resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bind@1.0.9:
+ resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ crelt@1.0.7:
+ resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ data-view-buffer@1.0.2:
+ resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-length@1.0.2:
+ resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-offset@1.0.1:
+ resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
+ engines: {node: '>= 0.4'}
+
+ debug@3.2.7:
+ resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
+ doctrine@2.1.0:
+ resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
+ engines: {node: '>=0.10.0'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ empathic@2.0.1:
+ resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
+ engines: {node: '>=14'}
+
+ enhanced-resolve@5.24.1:
+ resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==}
+ engines: {node: '>=10.13.0'}
+
+ es-abstract-get@1.0.0:
+ resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
+ engines: {node: '>= 0.4'}
+
+ es-abstract@1.24.2:
+ resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-iterator-helpers@1.3.3:
+ resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ es-shim-unscopables@1.1.0:
+ resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+ engines: {node: '>= 0.4'}
+
+ es-to-primitive@1.3.4:
+ resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.25.5:
+ resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-compat-utils@0.5.1:
+ resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ eslint: '>=6.0.0'
+
+ eslint-import-resolver-node@0.3.10:
+ resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
+
+ eslint-module-utils@2.14.0:
+ resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: '*'
+ eslint-import-resolver-node: '*'
+ eslint-import-resolver-typescript: '*'
+ eslint-import-resolver-webpack: '*'
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+ eslint:
+ optional: true
+ eslint-import-resolver-node:
+ optional: true
+ eslint-import-resolver-typescript:
+ optional: true
+ eslint-import-resolver-webpack:
+ optional: true
+
+ eslint-plugin-depend@1.3.1:
+ resolution: {integrity: sha512-1uo2rFAr9vzNrCYdp7IBZRB54LiyVxfaIso0R6/QV3t6Dax6DTbW/EV2Hktf0f4UtmGHK8UyzJWI382pwW04jw==}
+
+ eslint-plugin-es-x@7.8.0:
+ resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ eslint: '>=8'
+
+ eslint-plugin-import@2.32.0:
+ resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+
+ eslint-plugin-json-schema-validator@5.1.0:
+ resolution: {integrity: sha512-ZmVyxRIjm58oqe2kTuy90PpmZPrrKvOjRPXKzq8WCgRgAkidCgm5X8domL2KSfadZ3QFAmifMgGTcVNhZ5ez2g==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ eslint: '>=6.0.0'
+
+ eslint-plugin-n@17.10.3:
+ resolution: {integrity: sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: '>=8.23.0'
+
+ eslint-plugin-obsidianmd@0.1.9:
+ resolution: {integrity: sha512-/gyo5vky3Y7re4BtT/8MQbHU5Wes4o6VRqas3YmXE7aTCnMsdV0kfzV1GDXJN9Hrsc9UQPoeKUMiapKL0aGE4g==}
+ engines: {node: '>= 18'}
+ hasBin: true
+ peerDependencies:
+ '@eslint/js': ^9.30.1
+ '@eslint/json': 0.14.0
+ eslint: '>=9.0.0 <10.0.0'
+ obsidian: 1.8.7
+ typescript-eslint: ^8.35.1
+
+ eslint-plugin-react@7.37.3:
+ resolution: {integrity: sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
+
+ eslint-plugin-security@1.4.0:
+ resolution: {integrity: sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA==}
+
+ eslint-plugin-security@2.1.1:
+ resolution: {integrity: sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w==}
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint@9.39.4:
+ resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ espree@9.6.1:
+ resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fast-uri@3.1.3:
+ resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==}
+
+ fastq@1.20.1:
+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ function.prototype.name@1.2.0:
+ resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
+ engines: {node: '>= 0.4'}
+
+ functions-have-names@1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+
+ generator-function@2.0.1:
+ resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
+ engines: {node: '>= 0.4'}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-symbol-description@1.1.0:
+ resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
+ engines: {node: '>= 0.4'}
+
+ get-tsconfig@4.14.0:
+ resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
+
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@15.15.0:
+ resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
+ engines: {node: '>=18'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ graphemer@1.4.0:
+ resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+
+ has-bigints@1.1.0:
+ resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-proto@1.2.0:
+ resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ internal-slot@1.1.0:
+ resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
+ engines: {node: '>= 0.4'}
+
+ is-array-buffer@3.0.5:
+ resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
+ engines: {node: '>= 0.4'}
+
+ is-async-function@2.1.1:
+ resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
+ engines: {node: '>= 0.4'}
+
+ is-bigint@1.1.0:
+ resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
+ engines: {node: '>= 0.4'}
+
+ is-boolean-object@1.2.2:
+ resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
+ engines: {node: '>= 0.4'}
+
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
+ is-data-view@1.0.2:
+ resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
+ engines: {node: '>= 0.4'}
+
+ is-date-object@1.1.0:
+ resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
+ engines: {node: '>= 0.4'}
+
+ is-document.all@1.0.0:
+ resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-finalizationregistry@1.1.1:
+ resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
+ engines: {node: '>= 0.4'}
+
+ is-generator-function@1.1.2:
+ resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
+ engines: {node: '>= 0.4'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-map@2.0.3:
+ resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
+ engines: {node: '>= 0.4'}
+
+ is-negative-zero@2.0.3:
+ resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
+ engines: {node: '>= 0.4'}
+
+ is-number-object@1.1.1:
+ resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
+ engines: {node: '>= 0.4'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ is-regex@1.2.1:
+ resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+ engines: {node: '>= 0.4'}
+
+ is-set@2.0.3:
+ resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
+ engines: {node: '>= 0.4'}
+
+ is-shared-array-buffer@1.0.4:
+ resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
+ engines: {node: '>= 0.4'}
+
+ is-string@1.1.1:
+ resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
+ engines: {node: '>= 0.4'}
+
+ is-symbol@1.1.1:
+ resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
+ engines: {node: '>= 0.4'}
+
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
+ is-weakmap@2.0.2:
+ resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
+ engines: {node: '>= 0.4'}
+
+ is-weakref@1.1.1:
+ resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
+ engines: {node: '>= 0.4'}
+
+ is-weakset@2.0.4:
+ resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
+ engines: {node: '>= 0.4'}
+
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ iterator.prototype@1.1.5:
+ resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
+ engines: {node: '>= 0.4'}
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.3.0:
+ resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-migrate@2.0.0:
+ resolution: {integrity: sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@1.0.2:
+ resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
+ hasBin: true
+
+ jsonc-eslint-parser@2.4.2:
+ resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
+ engines: {node: '>=4.0'}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+ minimatch@8.0.7:
+ resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ module-replacements@2.11.0:
+ resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==}
+
+ moment@2.29.4:
+ resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-exports-info@1.6.2:
+ resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
+ engines: {node: '>= 0.4'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ object.assign@4.1.7:
+ resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+ engines: {node: '>= 0.4'}
+
+ object.entries@1.1.9:
+ resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
+ engines: {node: '>= 0.4'}
+
+ object.fromentries@2.0.8:
+ resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
+ engines: {node: '>= 0.4'}
+
+ object.groupby@1.0.3:
+ resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
+ engines: {node: '>= 0.4'}
+
+ object.values@1.2.1:
+ resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
+ engines: {node: '>= 0.4'}
+
+ obsidian@1.13.1:
+ resolution: {integrity: sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==}
+ peerDependencies:
+ '@codemirror/state': 6.5.0
+ '@codemirror/view': 6.38.6
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ own-keys@1.0.1:
+ resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
+ engines: {node: '>= 0.4'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ reflect.getprototypeof@1.0.10:
+ resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
+ engines: {node: '>= 0.4'}
+
+ regexp-tree@0.1.27:
+ resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
+ hasBin: true
+
+ regexp.prototype.flags@1.5.4:
+ resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
+ engines: {node: '>= 0.4'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+ resolve@2.0.0-next.7:
+ resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ ret@0.1.15:
+ resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
+ engines: {node: '>=0.12'}
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+ safe-array-concat@1.1.4:
+ resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
+ engines: {node: '>=0.4'}
+
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+ safe-push-apply@1.0.0:
+ resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex-test@1.1.0:
+ resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex@1.1.0:
+ resolution: {integrity: sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==}
+
+ safe-regex@2.1.1:
+ resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
+ set-function-name@2.0.2:
+ resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
+ engines: {node: '>= 0.4'}
+
+ set-proto@1.0.0:
+ resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
+ engines: {node: '>= 0.4'}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
+ stop-iteration-iterator@1.1.0:
+ resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.matchall@4.0.12:
+ resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.repeat@1.0.0:
+ resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
+
+ string.prototype.trim@1.2.11:
+ resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimend@1.0.10:
+ resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimstart@1.0.8:
+ resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
+ engines: {node: '>= 0.4'}
+
+ strip-bom@3.0.0:
+ resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
+ engines: {node: '>=4'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ style-mod@4.1.3:
+ resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ synckit@0.9.3:
+ resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ toml-eslint-parser@0.9.3:
+ resolution: {integrity: sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ tsconfig-paths@3.15.0:
+ resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
+
+ tslib@2.4.0:
+ resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ tunnel-agent@0.6.0:
+ resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-length@1.0.3:
+ resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-offset@1.0.4:
+ resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-length@1.0.8:
+ resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
+ engines: {node: '>= 0.4'}
+
+ typescript-eslint@8.35.1:
+ resolution: {integrity: sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.9.0'
+
+ typescript@5.4.5:
+ resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ unbox-primitive@1.1.0:
+ resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
+ engines: {node: '>= 0.4'}
+
+ undici-types@5.26.5:
+ resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
+ which-boxed-primitive@1.1.1:
+ resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
+ engines: {node: '>= 0.4'}
+
+ which-builtin-type@1.2.1:
+ resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
+ engines: {node: '>= 0.4'}
+
+ which-collection@1.0.2:
+ resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
+ engines: {node: '>= 0.4'}
+
+ which-typed-array@1.1.22:
+ resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
+ engines: {node: '>= 0.4'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ yaml-eslint-parser@1.3.2:
+ resolution: {integrity: sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==}
+ engines: {node: ^14.17.0 || >=16.0.0}
+
+ yaml@2.9.0:
+ resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+snapshots:
+
+ '@codemirror/state@6.5.0':
+ dependencies:
+ '@marijn/find-cluster-break': 1.0.3
+
+ '@codemirror/view@6.38.6':
+ dependencies:
+ '@codemirror/state': 6.5.0
+ crelt: 1.0.7
+ style-mod: 4.1.3
+ w3c-keyname: 2.2.8
+
+ '@esbuild/aix-ppc64@0.25.5':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.5':
+ optional: true
+
+ '@esbuild/android-arm@0.25.5':
+ optional: true
+
+ '@esbuild/android-x64@0.25.5':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.5':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.5':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.5':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.5':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.5':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.5':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.5':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.5':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.5':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.5':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.5':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.5':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.5':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.5':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.5':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.5':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.5':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.5':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.5':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.5':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.5':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
+ dependencies:
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.2':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.5':
+ dependencies:
+ ajv: 6.15.0
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.3.0
+ minimatch: 3.1.5
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.30.1': {}
+
+ '@eslint/js@9.39.4': {}
+
+ '@eslint/json@0.14.0':
+ dependencies:
+ '@eslint/core': 0.17.0
+ '@eslint/plugin-kit': 0.4.1
+ '@humanwhocodes/momoa': 3.3.10
+ natural-compare: 1.4.0
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@humanfs/core@0.19.2':
+ dependencies:
+ '@humanfs/types': 0.15.0
+
+ '@humanfs/node@0.16.8':
+ dependencies:
+ '@humanfs/core': 0.19.2
+ '@humanfs/types': 0.15.0
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanfs/types@0.15.0': {}
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/momoa@3.3.10': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@marijn/find-cluster-break@1.0.3': {}
+
+ '@microsoft/eslint-plugin-sdl@1.1.0(eslint@9.39.4(jiti@2.6.1))':
+ dependencies:
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-plugin-n: 17.10.3(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-react: 7.37.3(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-security: 1.4.0
+
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.20.1
+
+ '@pkgr/core@0.1.2': {}
+
+ '@rtsao/scc@1.1.0': {}
+
+ '@types/codemirror@5.60.8':
+ dependencies:
+ '@types/tern': 0.23.9
+
+ '@types/eslint@8.56.2':
+ dependencies:
+ '@types/estree': 1.0.9
+ '@types/json-schema': 7.0.15
+
+ '@types/estree@1.0.9': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/json5@0.0.29': {}
+
+ '@types/node@20.12.12':
+ dependencies:
+ undici-types: 5.26.5
+
+ '@types/node@20.19.43':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/tern@0.23.9':
+ dependencies:
+ '@types/estree': 1.0.9
+
+ '@typescript-eslint/eslint-plugin@8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.35.1
+ '@typescript-eslint/type-utils': 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.35.1
+ eslint: 9.39.4(jiti@2.6.1)
+ graphemer: 1.4.0
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.35.1
+ '@typescript-eslint/types': 8.35.1
+ '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.35.1
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.35.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.35.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.35.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.35.1':
+ dependencies:
+ '@typescript-eslint/types': 8.35.1
+ '@typescript-eslint/visitor-keys': 8.35.1
+
+ '@typescript-eslint/tsconfig-utils@8.35.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.35.1': {}
+
+ '@typescript-eslint/typescript-estree@8.35.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.35.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.35.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.35.1
+ '@typescript-eslint/visitor-keys': 8.35.1
+ debug: 4.4.3
+ fast-glob: 3.3.3
+ is-glob: 4.0.3
+ minimatch: 9.0.9
+ semver: 7.8.5
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ '@typescript-eslint/scope-manager': 8.35.1
+ '@typescript-eslint/types': 8.35.1
+ '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.35.1':
+ dependencies:
+ '@typescript-eslint/types': 8.35.1
+ eslint-visitor-keys: 4.2.1
+
+ acorn-jsx@5.3.2(acorn@8.17.0):
+ dependencies:
+ acorn: 8.17.0
+
+ acorn@8.17.0: {}
+
+ ajv@6.15.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ajv@8.20.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.3
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ argparse@2.0.1: {}
+
+ array-buffer-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ is-array-buffer: 3.0.5
+
+ array-includes@3.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ is-string: 1.1.1
+ math-intrinsics: 1.1.0
+
+ array.prototype.findlast@1.2.5:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.findlastindex@1.2.6:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flat@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flatmap@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.tosorted@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-shim-unscopables: 1.1.0
+
+ arraybuffer.prototype.slice@1.0.4:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ is-array-buffer: 3.0.5
+
+ async-function@1.0.0: {}
+
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+ balanced-match@1.0.2: {}
+
+ brace-expansion@1.1.15:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@2.1.1:
+ dependencies:
+ balanced-match: 1.0.2
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bind@1.0.9:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ callsites@3.1.0: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ concat-map@0.0.1: {}
+
+ crelt@1.0.7: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ data-view-buffer@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-offset@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ debug@3.2.7:
+ dependencies:
+ ms: 2.1.3
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-is@0.1.4: {}
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
+ doctrine@2.1.0:
+ dependencies:
+ esutils: 2.0.3
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ empathic@2.0.1: {}
+
+ enhanced-resolve@5.24.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
+ es-abstract-get@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ is-callable: 1.2.7
+ object-inspect: 1.13.4
+
+ es-abstract@1.24.2:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ arraybuffer.prototype.slice: 1.0.4
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ data-view-buffer: 1.0.2
+ data-view-byte-length: 1.0.2
+ data-view-byte-offset: 1.0.1
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-set-tostringtag: 2.1.0
+ es-to-primitive: 1.3.4
+ function.prototype.name: 1.2.0
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ get-symbol-description: 1.1.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ internal-slot: 1.1.0
+ is-array-buffer: 3.0.5
+ is-callable: 1.2.7
+ is-data-view: 1.0.2
+ is-negative-zero: 2.0.3
+ is-regex: 1.2.1
+ is-set: 2.0.3
+ is-shared-array-buffer: 1.0.4
+ is-string: 1.1.1
+ is-typed-array: 1.1.15
+ is-weakref: 1.1.1
+ math-intrinsics: 1.1.0
+ object-inspect: 1.13.4
+ object-keys: 1.1.1
+ object.assign: 4.1.7
+ own-keys: 1.0.1
+ regexp.prototype.flags: 1.5.4
+ safe-array-concat: 1.1.4
+ safe-push-apply: 1.0.0
+ safe-regex-test: 1.1.0
+ set-proto: 1.0.0
+ stop-iteration-iterator: 1.1.0
+ string.prototype.trim: 1.2.11
+ string.prototype.trimend: 1.0.10
+ string.prototype.trimstart: 1.0.8
+ typed-array-buffer: 1.0.3
+ typed-array-byte-length: 1.0.3
+ typed-array-byte-offset: 1.0.4
+ typed-array-length: 1.0.8
+ unbox-primitive: 1.1.0
+ which-typed-array: 1.1.22
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-iterator-helpers@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-set-tostringtag: 2.1.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ iterator.prototype: 1.1.5
+ math-intrinsics: 1.1.0
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ es-shim-unscopables@1.1.0:
+ dependencies:
+ hasown: 2.0.4
+
+ es-to-primitive@1.3.4:
+ dependencies:
+ es-abstract-get: 1.0.0
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ is-callable: 1.2.7
+ is-date-object: 1.1.0
+ is-symbol: 1.1.1
+
+ esbuild@0.25.5:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.5
+ '@esbuild/android-arm': 0.25.5
+ '@esbuild/android-arm64': 0.25.5
+ '@esbuild/android-x64': 0.25.5
+ '@esbuild/darwin-arm64': 0.25.5
+ '@esbuild/darwin-x64': 0.25.5
+ '@esbuild/freebsd-arm64': 0.25.5
+ '@esbuild/freebsd-x64': 0.25.5
+ '@esbuild/linux-arm': 0.25.5
+ '@esbuild/linux-arm64': 0.25.5
+ '@esbuild/linux-ia32': 0.25.5
+ '@esbuild/linux-loong64': 0.25.5
+ '@esbuild/linux-mips64el': 0.25.5
+ '@esbuild/linux-ppc64': 0.25.5
+ '@esbuild/linux-riscv64': 0.25.5
+ '@esbuild/linux-s390x': 0.25.5
+ '@esbuild/linux-x64': 0.25.5
+ '@esbuild/netbsd-arm64': 0.25.5
+ '@esbuild/netbsd-x64': 0.25.5
+ '@esbuild/openbsd-arm64': 0.25.5
+ '@esbuild/openbsd-x64': 0.25.5
+ '@esbuild/sunos-x64': 0.25.5
+ '@esbuild/win32-arm64': 0.25.5
+ '@esbuild/win32-ia32': 0.25.5
+ '@esbuild/win32-x64': 0.25.5
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ eslint: 9.39.4(jiti@2.6.1)
+ semver: 7.8.5
+
+ eslint-import-resolver-node@0.3.10:
+ dependencies:
+ debug: 3.2.7
+ is-core-module: 2.16.2
+ resolve: 2.0.0-next.7
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-module-utils@2.14.0(@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ debug: 3.2.7
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-import-resolver-node: 0.3.10
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-depend@1.3.1:
+ dependencies:
+ empathic: 2.0.1
+ module-replacements: 2.11.0
+ semver: 7.8.5
+
+ eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ '@eslint-community/regexpp': 4.12.2
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1))
+
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ '@rtsao/scc': 1.1.0
+ array-includes: 3.1.9
+ array.prototype.findlastindex: 1.2.6
+ array.prototype.flat: 1.3.3
+ array.prototype.flatmap: 1.3.3
+ debug: 3.2.7
+ doctrine: 2.1.0
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-import-resolver-node: 0.3.10
+ eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(jiti@2.6.1))
+ hasown: 2.0.4
+ is-core-module: 2.16.2
+ is-glob: 4.0.3
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ object.groupby: 1.0.3
+ object.values: 1.2.1
+ semver: 6.3.1
+ string.prototype.trimend: 1.0.10
+ tsconfig-paths: 3.15.0
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ transitivePeerDependencies:
+ - eslint-import-resolver-typescript
+ - eslint-import-resolver-webpack
+ - supports-color
+
+ eslint-plugin-json-schema-validator@5.1.0(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ ajv: 8.20.0
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1))
+ json-schema-migrate: 2.0.0
+ jsonc-eslint-parser: 2.4.2
+ minimatch: 8.0.7
+ synckit: 0.9.3
+ toml-eslint-parser: 0.9.3
+ tunnel-agent: 0.6.0
+ yaml-eslint-parser: 1.3.2
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-n@17.10.3(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ enhanced-resolve: 5.24.1
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-plugin-es-x: 7.8.0(eslint@9.39.4(jiti@2.6.1))
+ get-tsconfig: 4.14.0
+ globals: 15.15.0
+ ignore: 5.3.2
+ minimatch: 9.0.9
+ semver: 7.8.5
+
+ eslint-plugin-obsidianmd@0.1.9(@eslint/js@9.30.1)(@eslint/json@0.14.0)(@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(obsidian@1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6))(typescript-eslint@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)):
+ dependencies:
+ '@eslint/js': 9.30.1
+ '@eslint/json': 0.14.0
+ '@microsoft/eslint-plugin-sdl': 1.1.0(eslint@9.39.4(jiti@2.6.1))
+ '@types/eslint': 8.56.2
+ '@types/node': 20.12.12
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-plugin-depend: 1.3.1
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-json-schema-validator: 5.1.0(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-security: 2.1.1
+ globals: 14.0.0
+ obsidian: 1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6)
+ typescript: 5.4.5
+ typescript-eslint: 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ transitivePeerDependencies:
+ - '@typescript-eslint/parser'
+ - eslint-import-resolver-typescript
+ - eslint-import-resolver-webpack
+ - supports-color
+
+ eslint-plugin-react@7.37.3(eslint@9.39.4(jiti@2.6.1)):
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.findlast: 1.2.5
+ array.prototype.flatmap: 1.3.3
+ array.prototype.tosorted: 1.1.4
+ doctrine: 2.1.0
+ es-iterator-helpers: 1.3.3
+ eslint: 9.39.4(jiti@2.6.1)
+ estraverse: 5.3.0
+ hasown: 2.0.4
+ jsx-ast-utils: 3.3.5
+ minimatch: 3.1.5
+ object.entries: 1.1.9
+ object.fromentries: 2.0.8
+ object.values: 1.2.1
+ prop-types: 15.8.1
+ resolve: 2.0.0-next.7
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.12
+ string.prototype.repeat: 1.0.0
+
+ eslint-plugin-security@1.4.0:
+ dependencies:
+ safe-regex: 1.1.0
+
+ eslint-plugin-security@2.1.1:
+ dependencies:
+ safe-regex: 2.1.1
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint@9.39.4(jiti@2.6.1):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.2
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.5
+ '@eslint/js': 9.39.4
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.8
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.9
+ ajv: 6.15.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.6.1
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.17.0
+ acorn-jsx: 5.3.2(acorn@8.17.0)
+ eslint-visitor-keys: 4.2.1
+
+ espree@9.6.1:
+ dependencies:
+ acorn: 8.17.0
+ acorn-jsx: 5.3.2(acorn@8.17.0)
+ eslint-visitor-keys: 3.4.3
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ esutils@2.0.3: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fast-uri@3.1.3: {}
+
+ fastq@1.20.1:
+ dependencies:
+ reusify: 1.1.0
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.4.2
+ keyv: 4.5.4
+
+ flatted@3.4.2: {}
+
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
+ function-bind@1.1.2: {}
+
+ function.prototype.name@1.2.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+ hasown: 2.0.4
+ is-callable: 1.2.7
+ is-document.all: 1.0.0
+
+ functions-have-names@1.2.3: {}
+
+ generator-function@2.0.1: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ get-symbol-description@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+
+ get-tsconfig@4.14.0:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@15.15.0: {}
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+ gopd@1.2.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ graphemer@1.4.0: {}
+
+ has-bigints@1.1.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-proto@1.2.0:
+ dependencies:
+ dunder-proto: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ internal-slot@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ hasown: 2.0.4
+ side-channel: 1.1.1
+
+ is-array-buffer@3.0.5:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ is-async-function@2.1.1:
+ dependencies:
+ async-function: 1.0.0
+ call-bound: 1.0.4
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-bigint@1.1.0:
+ dependencies:
+ has-bigints: 1.1.0
+
+ is-boolean-object@1.2.2:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-callable@1.2.7: {}
+
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.4
+
+ is-data-view@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ is-typed-array: 1.1.15
+
+ is-date-object@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-document.all@1.0.0:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-extglob@2.1.1: {}
+
+ is-finalizationregistry@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-generator-function@1.1.2:
+ dependencies:
+ call-bound: 1.0.4
+ generator-function: 2.0.1
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-map@2.0.3: {}
+
+ is-negative-zero@2.0.3: {}
+
+ is-number-object@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-number@7.0.0: {}
+
+ is-regex@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ is-set@2.0.3: {}
+
+ is-shared-array-buffer@1.0.4:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-string@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-symbol@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-symbols: 1.1.0
+ safe-regex-test: 1.1.0
+
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.22
+
+ is-weakmap@2.0.2: {}
+
+ is-weakref@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-weakset@2.0.4:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ isarray@2.0.5: {}
+
+ isexe@2.0.0: {}
+
+ iterator.prototype@1.1.5:
+ dependencies:
+ define-data-property: 1.1.4
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ has-symbols: 1.1.0
+ set-function-name: 2.0.2
+
+ jiti@2.6.1: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.3.0:
+ dependencies:
+ argparse: 2.0.1
+
+ json-buffer@3.0.1: {}
+
+ json-schema-migrate@2.0.0:
+ dependencies:
+ ajv: 8.20.0
+
+ json-schema-traverse@0.4.1: {}
+
+ json-schema-traverse@1.0.0: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@1.0.2:
+ dependencies:
+ minimist: 1.2.8
+
+ jsonc-eslint-parser@2.4.2:
+ dependencies:
+ acorn: 8.17.0
+ eslint-visitor-keys: 3.4.3
+ espree: 9.6.1
+ semver: 7.8.5
+
+ jsx-ast-utils@3.3.5:
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.flat: 1.3.3
+ object.assign: 4.1.7
+ object.values: 1.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ math-intrinsics@1.1.0: {}
+
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.2
+
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.15
+
+ minimatch@8.0.7:
+ dependencies:
+ brace-expansion: 2.1.1
+
+ minimatch@9.0.9:
+ dependencies:
+ brace-expansion: 2.1.1
+
+ minimist@1.2.8: {}
+
+ module-replacements@2.11.0: {}
+
+ moment@2.29.4: {}
+
+ ms@2.1.3: {}
+
+ natural-compare@1.4.0: {}
+
+ node-exports-info@1.6.2:
+ dependencies:
+ array.prototype.flatmap: 1.3.3
+ es-errors: 1.3.0
+ object.entries: 1.1.9
+ semver: 6.3.1
+
+ object-assign@4.1.1: {}
+
+ object-inspect@1.13.4: {}
+
+ object-keys@1.1.1: {}
+
+ object.assign@4.1.7:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+ has-symbols: 1.1.0
+ object-keys: 1.1.1
+
+ object.entries@1.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ object.fromentries@2.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+
+ object.groupby@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ object.values@1.2.1:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ obsidian@1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6):
+ dependencies:
+ '@codemirror/state': 6.5.0
+ '@codemirror/view': 6.38.6
+ '@types/codemirror': 5.60.8
+ moment: 2.29.4
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ own-keys@1.0.1:
+ dependencies:
+ get-intrinsic: 1.3.0
+ object-keys: 1.1.1
+ safe-push-apply: 1.0.0
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ path-parse@1.0.7: {}
+
+ picomatch@2.3.2: {}
+
+ possible-typed-array-names@1.1.0: {}
+
+ prelude-ls@1.2.1: {}
+
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ punycode@2.3.1: {}
+
+ queue-microtask@1.2.3: {}
+
+ react-is@16.13.1: {}
+
+ reflect.getprototypeof@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ which-builtin-type: 1.2.1
+
+ regexp-tree@0.1.27: {}
+
+ regexp.prototype.flags@1.5.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-errors: 1.3.0
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ set-function-name: 2.0.2
+
+ require-from-string@2.0.2: {}
+
+ resolve-from@4.0.0: {}
+
+ resolve-pkg-maps@1.0.0: {}
+
+ resolve@2.0.0-next.7:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ node-exports-info: 1.6.2
+ object-keys: 1.1.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ ret@0.1.15: {}
+
+ reusify@1.1.0: {}
+
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
+ safe-array-concat@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ has-symbols: 1.1.0
+ isarray: 2.0.5
+
+ safe-buffer@5.2.1: {}
+
+ safe-push-apply@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ isarray: 2.0.5
+
+ safe-regex-test@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-regex: 1.2.1
+
+ safe-regex@1.1.0:
+ dependencies:
+ ret: 0.1.15
+
+ safe-regex@2.1.1:
+ dependencies:
+ regexp-tree: 0.1.27
+
+ semver@6.3.1: {}
+
+ semver@7.8.5: {}
+
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
+ set-function-name@2.0.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+
+ set-proto@1.0.0:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ stop-iteration-iterator@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ internal-slot: 1.1.0
+
+ string.prototype.matchall@4.0.12:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ regexp.prototype.flags: 1.5.4
+ set-function-name: 2.0.2
+ side-channel: 1.1.1
+
+ string.prototype.repeat@1.0.0:
+ dependencies:
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.trim@1.2.11:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-data-property: 1.1.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ has-property-descriptors: 1.0.2
+ safe-regex-test: 1.1.0
+
+ string.prototype.trimend@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ string.prototype.trimstart@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ strip-bom@3.0.0: {}
+
+ strip-json-comments@3.1.1: {}
+
+ style-mod@4.1.3: {}
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ synckit@0.9.3:
+ dependencies:
+ '@pkgr/core': 0.1.2
+ tslib: 2.8.1
+
+ tapable@2.3.3: {}
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ toml-eslint-parser@0.9.3:
+ dependencies:
+ eslint-visitor-keys: 3.4.3
+
+ ts-api-utils@2.5.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ tsconfig-paths@3.15.0:
+ dependencies:
+ '@types/json5': 0.0.29
+ json5: 1.0.2
+ minimist: 1.2.8
+ strip-bom: 3.0.0
+
+ tslib@2.4.0: {}
+
+ tslib@2.8.1: {}
+
+ tunnel-agent@0.6.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-length@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-offset@1.0.4:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+ reflect.getprototypeof: 1.0.10
+
+ typed-array-length@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ is-typed-array: 1.1.15
+ possible-typed-array-names: 1.1.0
+ reflect.getprototypeof: 1.0.10
+
+ typescript-eslint@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.35.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@5.4.5: {}
+
+ typescript@5.9.3: {}
+
+ unbox-primitive@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-bigints: 1.1.0
+ has-symbols: 1.1.0
+ which-boxed-primitive: 1.1.1
+
+ undici-types@5.26.5: {}
+
+ undici-types@6.21.0: {}
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ w3c-keyname@2.2.8: {}
+
+ which-boxed-primitive@1.1.1:
+ dependencies:
+ is-bigint: 1.1.0
+ is-boolean-object: 1.2.2
+ is-number-object: 1.1.1
+ is-string: 1.1.1
+ is-symbol: 1.1.1
+
+ which-builtin-type@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ function.prototype.name: 1.2.0
+ has-tostringtag: 1.0.2
+ is-async-function: 2.1.1
+ is-date-object: 1.1.0
+ is-finalizationregistry: 1.1.1
+ is-generator-function: 1.1.2
+ is-regex: 1.2.1
+ is-weakref: 1.1.1
+ isarray: 2.0.5
+ which-boxed-primitive: 1.1.1
+ which-collection: 1.0.2
+ which-typed-array: 1.1.22
+
+ which-collection@1.0.2:
+ dependencies:
+ is-map: 2.0.3
+ is-set: 2.0.3
+ is-weakmap: 2.0.2
+ is-weakset: 2.0.4
+
+ which-typed-array@1.1.22:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ word-wrap@1.2.5: {}
+
+ yaml-eslint-parser@1.3.2:
+ dependencies:
+ eslint-visitor-keys: 3.4.3
+ yaml: 2.9.0
+
+ yaml@2.9.0: {}
+
+ yocto-queue@0.1.0: {}
diff --git a/surfsense_obsidian/pnpm-workspace.yaml b/surfsense_obsidian/pnpm-workspace.yaml
new file mode 100644
index 000000000..00f6fc470
--- /dev/null
+++ b/surfsense_obsidian/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ esbuild: set this to true or false
diff --git a/surfsense_obsidian/src/api-client.ts b/surfsense_obsidian/src/api-client.ts
index 114e531f7..b66373f02 100644
--- a/surfsense_obsidian/src/api-client.ts
+++ b/surfsense_obsidian/src/api-client.ts
@@ -7,7 +7,7 @@ import type {
NotePayload,
RenameAck,
RenameItem,
- SearchSpace,
+ Workspace,
SyncAck,
} from "./types";
@@ -94,14 +94,14 @@ export class SurfSenseApiClient {
return await this.request("GET", "/api/v1/obsidian/health");
}
- async listSearchSpaces(): Promise {
- const resp = await this.request(
+ async listWorkspaces(): Promise {
+ const resp = await this.request(
"GET",
- "/api/v1/searchspaces/"
+ "/api/v1/workspaces/"
);
if (Array.isArray(resp)) return resp;
- if (resp && Array.isArray((resp as { items?: SearchSpace[] }).items)) {
- return (resp as { items: SearchSpace[] }).items;
+ if (resp && Array.isArray((resp as { items?: Workspace[] }).items)) {
+ return (resp as { items: Workspace[] }).items;
}
return [];
}
@@ -114,7 +114,7 @@ export class SurfSenseApiClient {
}
async connect(input: {
- searchSpaceId: number;
+ workspaceId: number;
vaultId: string;
vaultName: string;
vaultFingerprint: string;
@@ -125,7 +125,7 @@ export class SurfSenseApiClient {
{
vault_id: input.vaultId,
vault_name: input.vaultName,
- search_space_id: input.searchSpaceId,
+ workspace_id: input.workspaceId,
vault_fingerprint: input.vaultFingerprint,
}
);
diff --git a/surfsense_obsidian/src/main.ts b/surfsense_obsidian/src/main.ts
index 6600b7145..ca5de7206 100644
--- a/surfsense_obsidian/src/main.ts
+++ b/surfsense_obsidian/src/main.ts
@@ -119,7 +119,7 @@ export default class SurfSensePlugin extends Plugin {
name: "Sync current note",
checkCallback: (checking) => {
const file = this.app.workspace.getActiveFile();
- if (!file || file.extension.toLowerCase() !== "md") return false;
+ if (file?.extension.toLowerCase() !== "md") return false;
if (checking) return true;
this.queue.enqueueUpsert(file.path);
void this.engine.flushQueue();
@@ -186,7 +186,7 @@ export default class SurfSensePlugin extends Plugin {
if (!changed) return;
this.engine?.refreshStatus();
this.notifyStatusChange();
- if (this.settings.searchSpaceId !== null) {
+ if (this.settings.workspaceId !== null) {
void this.engine.ensureConnected();
}
}
@@ -252,16 +252,24 @@ export default class SurfSensePlugin extends Plugin {
}
async loadSettings() {
- const data = (await this.loadData()) as Partial | null;
+ // One-time migration: the workspace was previously persisted as `searchSpaceId`.
+ // Destructure it out so the migrated value moves into `workspaceId` and the
+ // dead key is not spread back in / re-persisted.
+ const data = (await this.loadData()) as
+ | (Partial & { searchSpaceId?: number | null })
+ | null;
+ const { searchSpaceId: legacyWorkspaceId, ...persisted } = data ?? {};
this.settings = {
...DEFAULT_SETTINGS,
- ...(data ?? {}),
- queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
- tombstones: { ...(data?.tombstones ?? {}) },
- includeFolders: [...(data?.includeFolders ?? [])],
- excludeFolders: [...(data?.excludeFolders ?? [])],
- excludePatterns: data?.excludePatterns?.length
- ? [...data.excludePatterns]
+ ...persisted,
+ workspaceId:
+ persisted.workspaceId ?? legacyWorkspaceId ?? DEFAULT_SETTINGS.workspaceId,
+ queue: (persisted.queue ?? []).map((i: QueueItem) => ({ ...i })),
+ tombstones: { ...(persisted.tombstones ?? {}) },
+ includeFolders: [...(persisted.includeFolders ?? [])],
+ excludeFolders: [...(persisted.excludeFolders ?? [])],
+ excludePatterns: persisted.excludePatterns?.length
+ ? [...persisted.excludePatterns]
: [...DEFAULT_SETTINGS.excludePatterns],
};
}
diff --git a/surfsense_obsidian/src/settings.ts b/surfsense_obsidian/src/settings.ts
index 7f404fc97..79e5762a9 100644
--- a/surfsense_obsidian/src/settings.ts
+++ b/surfsense_obsidian/src/settings.ts
@@ -13,13 +13,13 @@ import { normalizeFolder, parseExcludePatterns } from "./excludes";
import { FolderSuggestModal } from "./folder-suggest-modal";
import type SurfSensePlugin from "./main";
import { STATUS_VISUALS } from "./status-visuals";
-import type { SearchSpace } from "./types";
+import type { Workspace } from "./types";
/** Plugin settings tab. */
export class SurfSenseSettingTab extends PluginSettingTab {
private readonly plugin: SurfSensePlugin;
- private searchSpaces: SearchSpace[] = [];
+ private workspaces: Workspace[] = [];
private loadingSpaces = false;
private connectionIndicator: HTMLElement | null = null;
private readonly onStatusChange = (): void => this.updateConnectionIndicator();
@@ -51,7 +51,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
const next = value.trim();
const previous = this.plugin.settings.serverUrl;
if (previous !== "" && next !== previous) {
- this.plugin.settings.searchSpaceId = null;
+ this.plugin.settings.workspaceId = null;
this.plugin.settings.connectorId = null;
}
this.plugin.settings.serverUrl = next;
@@ -80,7 +80,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
const next = value.trim();
const previous = this.plugin.settings.apiToken;
if (previous !== "" && next !== previous) {
- this.plugin.settings.searchSpaceId = null;
+ this.plugin.settings.workspaceId = null;
this.plugin.settings.connectorId = null;
}
this.plugin.settings.apiToken = next;
@@ -102,7 +102,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
await this.plugin.api.verifyToken();
new Notice("Surfsense: token verified.");
this.plugin.engine.refreshStatus({ force: true });
- await this.refreshSearchSpaces();
+ await this.refreshWorkspaces();
this.display();
} catch (err) {
this.handleApiError(err);
@@ -115,21 +115,21 @@ export class SurfSenseSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Search space")
.setDesc(
- "Which Surfsense search space this vault syncs into. Reload after changing your token.",
+ "Which Surfsense workspace this vault syncs into. Reload after changing your token.",
)
.addDropdown((drop) => {
- drop.addOption("", this.loadingSpaces ? "Loading…" : "Select a search space");
- for (const space of this.searchSpaces) {
+ drop.addOption("", this.loadingSpaces ? "Loading…" : "Select a workspace");
+ for (const space of this.workspaces) {
drop.addOption(String(space.id), space.name);
}
- if (settings.searchSpaceId !== null) {
- drop.setValue(String(settings.searchSpaceId));
+ if (settings.workspaceId !== null) {
+ drop.setValue(String(settings.workspaceId));
}
drop.onChange(async (value) => {
- this.plugin.settings.searchSpaceId = value ? Number(value) : null;
+ this.plugin.settings.workspaceId = value ? Number(value) : null;
this.plugin.settings.connectorId = null;
await this.plugin.saveSettings();
- if (this.plugin.settings.searchSpaceId !== null) {
+ if (this.plugin.settings.workspaceId !== null) {
try {
await this.plugin.engine.ensureConnected();
await this.plugin.engine.maybeReconcile(true);
@@ -144,9 +144,9 @@ export class SurfSenseSettingTab extends PluginSettingTab {
.addExtraButton((btn) =>
btn
.setIcon("refresh-ccw")
- .setTooltip("Reload search spaces")
+ .setTooltip("Reload workspaces")
.onClick(async () => {
- await this.refreshSearchSpaces();
+ await this.refreshWorkspaces();
this.display();
}),
);
@@ -319,13 +319,13 @@ export class SurfSenseSettingTab extends PluginSettingTab {
indicator.setAttr("title", visual.label);
}
- private async refreshSearchSpaces(): Promise {
+ private async refreshWorkspaces(): Promise {
this.loadingSpaces = true;
try {
- this.searchSpaces = await this.plugin.api.listSearchSpaces();
+ this.workspaces = await this.plugin.api.listWorkspaces();
} catch (err) {
this.handleApiError(err);
- this.searchSpaces = [];
+ this.workspaces = [];
} finally {
this.loadingSpaces = false;
}
diff --git a/surfsense_obsidian/src/sync-engine.ts b/surfsense_obsidian/src/sync-engine.ts
index 80594dd9e..921067a37 100644
--- a/surfsense_obsidian/src/sync-engine.ts
+++ b/surfsense_obsidian/src/sync-engine.ts
@@ -48,7 +48,7 @@ export interface SyncEngineSettings {
vaultId: string;
apiToken: string;
connectorId: number | null;
- searchSpaceId: number | null;
+ workspaceId: number | null;
includeFolders: string[];
excludeFolders: string[];
excludePatterns: string[];
@@ -96,7 +96,7 @@ export class SyncEngine {
this.setStatus("syncing", "Connecting to SurfSense…");
const settings = this.deps.getSettings();
- if (!settings.searchSpaceId) {
+ if (!settings.workspaceId) {
// No target yet — /health still surfaces auth/network errors.
try {
const health = await this.deps.apiClient.health();
@@ -124,7 +124,7 @@ export class SyncEngine {
*/
async ensureConnected(): Promise {
const settings = this.deps.getSettings();
- if (!settings.searchSpaceId) {
+ if (!settings.workspaceId) {
this.setStatus("idle");
return false;
}
@@ -132,7 +132,7 @@ export class SyncEngine {
try {
const fingerprint = await computeVaultFingerprint(this.deps.app);
const resp = await this.deps.apiClient.connect({
- searchSpaceId: settings.searchSpaceId,
+ workspaceId: settings.workspaceId,
vaultId: settings.vaultId,
vaultName: this.deps.app.vault.getName(),
vaultFingerprint: fingerprint,
@@ -272,7 +272,7 @@ export class SyncEngine {
this.refreshStatus({ force: true });
return;
}
- if (!settings.searchSpaceId) {
+ if (!settings.workspaceId) {
try {
const health = await this.deps.apiClient.health();
this.applyHealth(health);
@@ -543,7 +543,7 @@ export class SyncEngine {
const isError =
last === "auth-error" || last === "offline" || last === "error";
const s = this.deps.getSettings();
- const setupComplete = !!(s.apiToken && s.searchSpaceId && s.connectorId);
+ const setupComplete = !!(s.apiToken && s.workspaceId && s.connectorId);
if (isError && setupComplete) return;
}
this.setStatus(this.queueStatusKind(), this.statusDetail());
@@ -571,7 +571,7 @@ export class SyncEngine {
kind = "needs-setup";
detail = this.setupHint(s);
} else if (kind !== "auth-error" && kind !== "offline" && kind !== "error") {
- if (!s.searchSpaceId || !s.connectorId) {
+ if (!s.workspaceId || !s.connectorId) {
kind = "needs-setup";
detail = this.setupHint(s);
}
@@ -582,7 +582,7 @@ export class SyncEngine {
private setupHint(s: SyncEngineSettings): string {
if (!s.apiToken) return "Paste your API token in settings.";
- if (!s.searchSpaceId) return "Pick a search space in settings.";
+ if (!s.workspaceId) return "Pick a workspace in settings.";
return "Connecting…";
}
diff --git a/surfsense_obsidian/src/types.ts b/surfsense_obsidian/src/types.ts
index 192d34dc8..cfa9fbb74 100644
--- a/surfsense_obsidian/src/types.ts
+++ b/surfsense_obsidian/src/types.ts
@@ -3,7 +3,7 @@
export interface SurfsensePluginSettings {
serverUrl: string;
apiToken: string;
- searchSpaceId: number | null;
+ workspaceId: number | null;
connectorId: number | null;
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
vaultId: string;
@@ -25,7 +25,7 @@ export interface SurfsensePluginSettings {
export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
serverUrl: "https://surfsense.com",
apiToken: "",
- searchSpaceId: null,
+ workspaceId: null,
connectorId: null,
vaultId: "",
syncIntervalMinutes: 10,
@@ -107,7 +107,7 @@ export interface HeadingRef {
level: number;
}
-export interface SearchSpace {
+export interface Workspace {
id: number;
name: string;
description?: string;
@@ -117,7 +117,7 @@ export interface SearchSpace {
export interface ConnectResponse {
connector_id: number;
vault_id: string;
- search_space_id: number;
+ workspace_id: number;
capabilities: string[];
server_time_utc: string;
[key: string]: unknown;
diff --git a/surfsense_web/app/(home)/[slug]/page.tsx b/surfsense_web/app/(home)/[slug]/page.tsx
new file mode 100644
index 000000000..94a7912f7
--- /dev/null
+++ b/surfsense_web/app/(home)/[slug]/page.tsx
@@ -0,0 +1,94 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { ConnectorPage } from "@/components/connectors-marketing/connector-page";
+import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
+import { getAllConnectorSlugs, getConnector } from "@/lib/connectors-marketing";
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+// Only the known connector slugs are served; every other path falls through to 404.
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return getAllConnectorSlugs().map((slug) => ({ slug }));
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { slug } = await params;
+ const content = getConnector(slug);
+ if (!content) return { title: "Connector Not Found | SurfSense" };
+
+ const canonicalUrl = `https://www.surfsense.com/${content.slug}`;
+
+ return {
+ title: content.metaTitle,
+ description: content.metaDescription,
+ keywords: content.keywords,
+ alternates: { canonical: canonicalUrl },
+ openGraph: {
+ title: content.metaTitle,
+ description: content.metaDescription,
+ url: canonicalUrl,
+ siteName: "SurfSense",
+ type: "website",
+ images: [
+ {
+ url: "/og-image.png",
+ width: 1200,
+ height: 630,
+ alt: `${content.name} Scraper API on SurfSense`,
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: content.metaTitle,
+ description: content.metaDescription,
+ images: ["/og-image.png"],
+ },
+ };
+}
+
+export default async function ConnectorMarketingPage({ params }: PageProps) {
+ const { slug } = await params;
+ const content = getConnector(slug);
+ if (!content) notFound();
+
+ const canonicalUrl = `https://www.surfsense.com/${content.slug}`;
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/(home)/connectors/page.tsx b/surfsense_web/app/(home)/connectors/page.tsx
new file mode 100644
index 000000000..af6838337
--- /dev/null
+++ b/surfsense_web/app/(home)/connectors/page.tsx
@@ -0,0 +1,117 @@
+import { ArrowRight, Plug, Server } from "lucide-react";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getAllConnectors } from "@/lib/connectors-marketing";
+
+const canonicalUrl = "https://www.surfsense.com/connectors";
+
+const metaDescription =
+ "Platform-native scraper APIs for AI agents. Pull live data from the platforms your market uses through one typed API or the SurfSense MCP server. Explore every connector.";
+
+export const metadata: Metadata = {
+ title: "Scraper APIs for AI Agents: All Connectors | SurfSense",
+ description: metaDescription,
+ keywords: [
+ "scraper api",
+ "web scraping api",
+ "scraper api for ai agents",
+ "data connectors",
+ "mcp server",
+ "competitive intelligence platform",
+ ],
+ alternates: { canonical: canonicalUrl },
+ openGraph: {
+ title: "Scraper APIs for AI Agents: All Connectors | SurfSense",
+ description: metaDescription,
+ url: canonicalUrl,
+ siteName: "SurfSense",
+ type: "website",
+ images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense connectors" }],
+ },
+};
+
+export default function ConnectorsIndexPage() {
+ const connectors = getAllConnectors();
+
+ return (
+
+
+
+
+ Connectors for every platform your market uses
+
+
+ Each connector is a platform-native scraper API your AI agents can call directly, or
+ through the SurfSense MCP server. They are the live data behind the SurfSense{" "}
+
+ competitive intelligence platform
+
+ .
+
+
+
+
+ {connectors.map((connector) => {
+ const Icon = connector.icon;
+ return (
+
+
+
+
+
+ {connector.cardTitle ?? `${connector.name} API`}
+
+
+ {connector.heroLede}
+
+
+ Explore
+
+
+
+ );
+ })}
+ {/* Bespoke pages (not in the scrape-API registry): the two MCP directions. */}
+
+
+
+
+
SurfSense MCP Server
+
+ Give Claude, Cursor, or any MCP client native tools for your workspace: every scraper
+ API plus knowledge base search, reads, and writes. One API key.
+
+
+ Explore
+
+
+
+
+
+
+
+
External MCP Connectors
+
+ Bring any MCP server to your agents. Paste a config like you would in Cursor, tools
+ are auto-discovered, and Notion, Slack, Jira, and more connect with one-click OAuth.
+
+
+ Explore
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/(home)/external-mcp-connectors/page.tsx b/surfsense_web/app/(home)/external-mcp-connectors/page.tsx
new file mode 100644
index 000000000..f90a4ef99
--- /dev/null
+++ b/surfsense_web/app/(home)/external-mcp-connectors/page.tsx
@@ -0,0 +1,380 @@
+import { IconBrandGithub } from "@tabler/icons-react";
+import { ArrowRight, Check, Plug, ShieldCheck, Wrench } from "lucide-react";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
+import { Reveal } from "@/components/connectors-marketing/reveal";
+import { MarketingSection } from "@/components/marketing/section";
+import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
+import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import type { FaqItem } from "@/lib/connectors-marketing/types";
+
+const canonicalUrl = "https://www.surfsense.com/external-mcp-connectors";
+
+const metaDescription =
+ "External MCP connectors let your SurfSense agents use any MCP server. Paste a config, tools are auto-discovered, and every call runs with per-tool approval. Try it free.";
+
+export const metadata: Metadata = {
+ title: "External MCP Connectors: Add Any MCP Server | SurfSense",
+ description: metaDescription,
+ keywords: [
+ "mcp connector",
+ "external mcp connectors",
+ "what is an mcp connector",
+ "mcp client",
+ "add mcp server",
+ "connect mcp server",
+ "mcp integrations",
+ ],
+ alternates: { canonical: canonicalUrl },
+ openGraph: {
+ title: "External MCP Connectors: Add Any MCP Server | SurfSense",
+ description: metaDescription,
+ url: canonicalUrl,
+ siteName: "SurfSense",
+ type: "website",
+ images: [
+ { url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense external MCP connectors" },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "External MCP Connectors: Add Any MCP Server | SurfSense",
+ description: metaDescription,
+ images: ["/og-image.png"],
+ },
+};
+
+/* Mirrors the real server_config contract (stdio + HTTP transports). */
+const STDIO_CONFIG = `{
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
+ "env": { "LOG_LEVEL": "info" },
+ "transport": "stdio"
+}`;
+
+const HTTP_CONFIG = `{
+ "url": "https://mcp.example.com/mcp",
+ "headers": { "Authorization": "Bearer " },
+ "transport": "streamable-http"
+}`;
+
+const STEPS = [
+ {
+ icon: Plug,
+ title: "Paste a server config",
+ description:
+ "Add any MCP server the same way you would in Cursor: a local command for stdio servers, or a URL and headers for remote HTTP and SSE servers.",
+ },
+ {
+ icon: Wrench,
+ title: "Tools are auto-discovered",
+ description:
+ "SurfSense tests the connection and pulls the full tool list from the server. No manual tool configuration, no schema files to maintain.",
+ },
+ {
+ icon: ShieldCheck,
+ title: "Your agent uses them, safely",
+ description:
+ "Read-only tools run automatically. Anything that writes asks for your approval first, and you can trust a tool once to always allow it.",
+ },
+] as const;
+
+/** Hosted MCP apps with one-click OAuth (mirrors the backend MCP service registry). */
+const ONE_CLICK_APPS = [
+ "Notion",
+ "Slack",
+ "Jira",
+ "Confluence",
+ "Linear",
+ "ClickUp",
+ "Airtable",
+] as const;
+
+const FAQ: FaqItem[] = [
+ {
+ question: "What is an external MCP connector?",
+ answer:
+ "An external MCP connector links SurfSense to an outside MCP (Model Context Protocol) server, so your agents can call that server's tools. You add a server config once, its tools are auto-discovered, and every agent in your workspace can use them with per-tool approval.",
+ },
+ {
+ question: "How is this different from the SurfSense MCP server?",
+ answer:
+ "Direction. External MCP connectors make SurfSense the client: outside tools flow into your SurfSense agents. The SurfSense MCP server is the reverse: it exposes your workspace and the scraper APIs as tools inside Claude, Cursor, or any MCP client you already run.",
+ },
+ {
+ question: "Which MCP transports are supported?",
+ answer:
+ "All the common ones. Local stdio servers run as a process with a command, args, and environment variables. Remote servers connect over streamable HTTP, plain HTTP, or SSE with a URL and optional headers, which covers hosted MCP servers that require an auth token.",
+ },
+ {
+ question: "Is it safe to give an agent MCP tools?",
+ answer:
+ "Every MCP tool runs through SurfSense's permission layer. Read-only tools are allowed automatically, while any tool that can write or act asks for your approval before it executes. You can mark tools you rely on as trusted so they skip the prompt on later calls.",
+ },
+ {
+ question: "Can I connect Notion or Slack without writing a config?",
+ answer:
+ "Yes. Notion, Slack, Jira, Confluence, Linear, ClickUp, and Airtable connect through their official hosted MCP servers with one-click OAuth. SurfSense handles the token exchange and curates each app's tool list, so you sign in once and your agents can use them immediately.",
+ },
+];
+
+function ConfigCard() {
+ return (
+
+
Local server (stdio)
+
+ {STDIO_CONFIG}
+
+
Remote server (HTTP / SSE)
+
+ {HTTP_CONFIG}
+
+
+
+ Tools auto-discovered on connect
+
+
+ );
+}
+
+export default function ExternalMcpConnectorsPage() {
+ return (
+ <>
+
+
+
+
+ {/* Hero */}
+
+
+
+
+
+
+ External MCP connectors
+
+
+ Bring any external MCP server to your agents
+
+
+ External MCP connectors turn your SurfSense workspace into an MCP client. Add any
+ MCP server with the same config you'd use in Cursor, and its tools are
+ auto-discovered and handed to your agents, guarded by per-tool approval. Notion,
+ Slack, Jira, and more connect with one-click OAuth.
+
+
+
+
+ Start for free
+
+
+
+
+ Read the docs
+
+
+
+
+ GitHub
+
+
+
+
+
+
+
+
+ {/* How it works */}
+
+
+
+ From config to agent tool in three steps
+
+
+
+ {STEPS.map((step) => (
+
+
+
+
+
+
{step.title}
+
+ {step.description}
+
+
+
+ ))}
+
+
+
+ {/* One-click apps */}
+
+
+
+ Your work apps, no config required
+
+
+ These apps run on their official hosted MCP servers. Sign in once with OAuth and
+ SurfSense manages the tokens and curates each tool list, so your agents can search
+ Notion, read Slack threads, or file Jira issues alongside your market intelligence.
+
+
+
+
+ {ONE_CLICK_APPS.map((app) => (
+
+
+ {app}
+
+ ))}
+
+
+
+
+ {/* Connector vs server */}
+
+
+
+ External MCP connectors vs the SurfSense MCP server
+
+
+ They are two sides of the same protocol. The external MCP connectors on this page make
+ SurfSense a client : they consume tools from outside MCP servers. The{" "}
+
+ SurfSense MCP server
+ {" "}
+ does the reverse, exposing your workspace and scraper APIs like{" "}
+
+ Reddit
+ {" "}
+ and{" "}
+
+ Google Maps
+ {" "}
+ as native tools inside Claude, Cursor, or any agent you already run. Use both and data
+ flows in either direction.
+
+
+
+
+ {/* FAQ */}
+
+
+
+ External MCP connectors: frequently asked questions
+
+
+
+
+
+
+
+
+
+ {/* Closing CTA + related */}
+
+
+
+
+ Give your agents every tool they need
+
+
+ External MCP connectors are part of the SurfSense{" "}
+
+ competitive intelligence platform
+
+ . Start free, no credit card required.
+
+
+
+
+ Start for free
+
+
+
+
+ See pricing
+
+
+
+
+
+
+
+ All connectors
+
+
+ SurfSense MCP Server
+
+
+ Reddit API
+
+
+ YouTube API
+
+
+ Google Maps API
+
+
+ SERP API
+
+
+ Web Crawl API
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/(home)/mcp-server/page.tsx b/surfsense_web/app/(home)/mcp-server/page.tsx
new file mode 100644
index 000000000..6dfc69622
--- /dev/null
+++ b/surfsense_web/app/(home)/mcp-server/page.tsx
@@ -0,0 +1,420 @@
+import { IconBrandGithub } from "@tabler/icons-react";
+import { ArrowRight, Check, Database, KeyRound, Server, TerminalSquare } from "lucide-react";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
+import { Reveal } from "@/components/connectors-marketing/reveal";
+import { MarketingSection } from "@/components/marketing/section";
+import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
+import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
+import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import type { FaqItem } from "@/lib/connectors-marketing/types";
+
+const canonicalUrl = "https://www.surfsense.com/mcp-server";
+
+const metaDescription =
+ "The SurfSense MCP server gives Claude, Cursor, and any MCP client native tools for your workspace: scrape Reddit, YouTube, Google Maps, Google Search, and the web, plus full knowledge base access. One API key.";
+
+export const metadata: Metadata = {
+ title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
+ description: metaDescription,
+ keywords: [
+ "surfsense mcp server",
+ "mcp server",
+ "mcp server for web scraping",
+ "reddit mcp server",
+ "youtube mcp server",
+ "google maps mcp server",
+ "serp mcp server",
+ "mcp server for claude",
+ "mcp server for cursor",
+ "knowledge base mcp server",
+ ],
+ alternates: { canonical: canonicalUrl },
+ openGraph: {
+ title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
+ description: metaDescription,
+ url: canonicalUrl,
+ siteName: "SurfSense",
+ type: "website",
+ images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense MCP server" }],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
+ description: metaDescription,
+ images: ["/og-image.png"],
+ },
+};
+
+/* Mirrors surfsense_mcp/README.md: the real Cursor config. */
+const CURSOR_CONFIG = `{
+ "mcpServers": {
+ "surfsense": {
+ "command": "uv",
+ "args": ["run", "--directory", ".../surfsense_mcp",
+ "python", "-m", "surfsense_mcp"],
+ "env": {
+ "SURFSENSE_BASE_URL": "https://api.surfsense.com",
+ "SURFSENSE_API_KEY": "ss_pat_..."
+ }
+ }
+ }
+}`;
+
+const STEPS = [
+ {
+ icon: KeyRound,
+ title: "Create an API key",
+ description:
+ "In SurfSense, go to Settings, then API, and create a key. Enable API access on the workspaces you want your agents to reach. That key is all the server needs.",
+ },
+ {
+ icon: TerminalSquare,
+ title: "Add the server to your client",
+ description:
+ "Drop the config into Cursor's mcp.json, run claude mcp add for Claude Code, or paste it into Claude Desktop. Point it at the cloud or your own self-hosted instance.",
+ },
+ {
+ icon: Server,
+ title: "Your agent has the tools",
+ description:
+ "Every scraper and knowledge base operation shows up as a native, typed MCP tool. Your agent picks a workspace once and the server carries the context between calls.",
+ },
+] as const;
+
+/** Mirrors the tool registry in surfsense_mcp (see its README). */
+const TOOL_GROUPS = [
+ {
+ icon: Server,
+ title: "Live scrapers",
+ description: "Structured, current platform data. One returned item is one billable unit.",
+ tools: [
+ "surfsense_reddit_scrape",
+ "surfsense_youtube_scrape",
+ "surfsense_youtube_comments",
+ "surfsense_google_maps_scrape",
+ "surfsense_google_maps_reviews",
+ "surfsense_google_search",
+ "surfsense_web_crawl",
+ "surfsense_list_scraper_runs",
+ "surfsense_get_scraper_run",
+ ],
+ },
+ {
+ icon: Database,
+ title: "Knowledge base",
+ description: "Read and write the same knowledge base your SurfSense agents use.",
+ tools: [
+ "surfsense_search_knowledge_base",
+ "surfsense_list_documents",
+ "surfsense_get_document",
+ "surfsense_add_document",
+ "surfsense_upload_file",
+ "surfsense_update_document",
+ "surfsense_delete_document",
+ ],
+ },
+ {
+ icon: KeyRound,
+ title: "Workspace selector",
+ description: "Pick a workspace once; every later call defaults to it.",
+ tools: ["surfsense_list_workspaces", "surfsense_select_workspace"],
+ },
+] as const;
+
+const FAQ: FaqItem[] = [
+ {
+ question: "What is the SurfSense MCP server?",
+ answer:
+ "It is a Model Context Protocol server that exposes your SurfSense workspace to MCP clients like Claude Code, Cursor, and Claude Desktop. Your agents get native tools for every scraper API (Reddit, YouTube, Google Maps, Google Search, web crawl) and for searching, reading, and writing your knowledge base.",
+ },
+ {
+ question: "Which MCP clients does it work with?",
+ answer:
+ "Any MCP client that supports stdio servers. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI are documented with copy-paste configs on this page, and the same command works in custom agent harnesses built on the MCP SDK.",
+ },
+ {
+ question: "How is usage billed?",
+ answer:
+ "Exactly like the REST API, because the server is a thin layer over it. Scraper tools consume the same pay-as-you-go credits, priced per returned item, and knowledge base operations work within your plan. New accounts start with $5 of free credit.",
+ },
+ {
+ question: "Does it work with a self-hosted SurfSense?",
+ answer:
+ "Yes. The server talks to SurfSense purely over its REST API and imports no backend code, so pointing SURFSENSE_BASE_URL at your own instance is all it takes. It works with the cloud at api.surfsense.com the same way.",
+ },
+ {
+ question: "How does the agent know which workspace to use?",
+ answer:
+ "The server ships a workspace selector: the agent lists the workspaces your API key can access, selects one by name, and every later call defaults to it. Any tool also accepts a workspace override for a single call, and ids never need to be typed by hand.",
+ },
+];
+
+function ConfigCard() {
+ return (
+
+
.cursor/mcp.json
+
+ {CURSOR_CONFIG}
+
+
+
+ Works with Claude Code, Cursor, Claude Desktop, and any MCP client
+
+
+ );
+}
+
+export default function McpServerPage() {
+ return (
+ <>
+
+
+
+
+ {/* Hero */}
+
+
+
+
+
+
+ SurfSense MCP server
+
+
+ Give your agents SurfSense as native tools
+
+
+ The SurfSense MCP server hands Claude, Cursor, or any MCP client the whole platform:
+ scrape Reddit, YouTube, Google Maps, Google Search, and the open web, and search,
+ read, and write your knowledge base. One API key, typed tools, pay as you go.
+
+
+
+
+ Get your API key
+
+
+
+
+ Read the docs
+
+
+
+
+ GitHub
+
+
+
+
+
+
+
+
+ {/* How it works */}
+
+
+
+ From API key to agent tools in three steps
+
+
+
+ {STEPS.map((step) => (
+
+
+
+
+
+
{step.title}
+
+ {step.description}
+
+
+
+ ))}
+
+
+
+ {/* Per-agent setup */}
+
+
+
+ Step-by-step setup for every agent
+
+
+ Pick your client, follow its two steps, and paste the config. Replace the placeholder
+ path with your surfsense_mcp checkout and the key with one from API Playground → API
+ Keys — or grab a pre-filled config from the playground itself.
+
+
+
+
+
+
+
+ {/* Tools */}
+
+
+
+ Every tool the server exposes
+
+
+ The server is a thin layer over the SurfSense REST API: the same endpoints, the same
+ billing, no backend code imported. Whatever ships in the API shows up here.
+
+
+
+ {TOOL_GROUPS.map((group) => (
+
+
+
+
+
+
{group.title}
+
+ {group.description}
+
+
+ {group.tools.map((tool) => (
+
+ {tool}
+
+ ))}
+
+
+
+ ))}
+
+
+
+ {/* Server vs external connectors */}
+
+
+
+ The SurfSense MCP server vs external MCP connectors
+
+
+ They are two sides of the same protocol. The MCP server on this page pushes
+ SurfSense tools out to agents you already run in Claude, Cursor, or your own harness.{" "}
+
+ External MCP connectors
+ {" "}
+ do the reverse: they pull outside tools like Notion, Slack, and Jira into your
+ SurfSense agents. Use both and data flows in either direction.
+
+
+
+
+ {/* FAQ */}
+
+
+
+ SurfSense MCP server: frequently asked questions
+
+
+
+
+
+
+
+
+
+ {/* Closing CTA + related */}
+
+
+
+
+ Put live market data inside your agents
+
+
+ The MCP server is part of the SurfSense{" "}
+
+ competitive intelligence platform
+
+ . Start with $5 of free credit, no credit card required.
+
+
+
+
+ Start for free
+
+
+
+
+ See pricing
+
+
+
+
+
+
+
+ All connectors
+
+
+ External MCP Connectors
+
+
+ Reddit API
+
+
+ YouTube API
+
+
+ Google Maps API
+
+
+ SERP API
+
+
+ Web Crawl API
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx
index 367ef2dc5..825c647c1 100644
--- a/surfsense_web/app/(home)/page.tsx
+++ b/surfsense_web/app/(home)/page.tsx
@@ -1,29 +1,25 @@
-import dynamic from "next/dynamic";
import { AuthRedirect } from "@/components/homepage/auth-redirect";
-import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
-import { FeaturesCards } from "@/components/homepage/features-card";
+import { CommunityStrip } from "@/components/homepage/community-strip";
+import { CompareTable } from "@/components/homepage/compare-table";
+import { ConnectorGrid } from "@/components/homepage/connector-grid";
import { HeroSection } from "@/components/homepage/hero-section";
-
-const WhySurfSense = dynamic(() =>
- import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense }))
-);
-
-const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"));
-
-const CTAHomepage = dynamic(() =>
- import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage }))
-);
+import { HomeFaq } from "@/components/homepage/home-faq";
+import { HowItWorks } from "@/components/homepage/how-it-works";
+import { PersonaPaths } from "@/components/homepage/persona-paths";
+import { UseCasesRow } from "@/components/homepage/use-cases";
export default function HomePage() {
return (
-
+
);
}
diff --git a/surfsense_web/app/(home)/pricing/page.tsx b/surfsense_web/app/(home)/pricing/page.tsx
index 0b45deff4..666cb20d4 100644
--- a/surfsense_web/app/(home)/pricing/page.tsx
+++ b/surfsense_web/app/(home)/pricing/page.tsx
@@ -1,18 +1,74 @@
import type { Metadata } from "next";
import PricingBasic from "@/components/pricing/pricing-section";
+import { JsonLd } from "@/components/seo/json-ld";
+
+const canonicalUrl = "https://www.surfsense.com/pricing";
+
+const metaTitle = "SurfSense Pricing: Self-Host Free or Pay As You Go";
+const metaDescription =
+ "Self-host SurfSense for free from our open-source repo, or use the cloud with $5 of free credit and pay as you go at provider cost. No subscription.";
export const metadata: Metadata = {
- title: "Pricing | SurfSense - Free AI Workspace, Automations & Agents",
- description:
- "Explore SurfSense plans and pricing. Start free with 500 pages & $5 in premium credits. Run AI automations and agents, use ChatGPT, Claude AI, and premium AI models, and pay as you go at provider cost.",
+ title: metaTitle,
+ description: metaDescription,
+ keywords: [
+ "surfsense pricing",
+ "pay as you go ai platform",
+ "open source ai agent platform",
+ "self-hosted ai workspace",
+ "ai automation pricing",
+ "competitive intelligence pricing",
+ ],
alternates: {
- canonical: "https://www.surfsense.com/pricing",
+ canonical: canonicalUrl,
+ },
+ openGraph: {
+ title: metaTitle,
+ description: metaDescription,
+ url: canonicalUrl,
+ siteName: "SurfSense",
+ type: "website",
+ images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense pricing" }],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: metaTitle,
+ description: metaDescription,
+ images: ["/og-image.png"],
},
};
const page = () => {
return (
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/page.tsx
deleted file mode 100644
index 69aa47bc3..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/page.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default async function SearchSpaceDashboardPage({
- params,
-}: {
- params: Promise<{ search_space_id: string }>;
-}) {
- const { search_space_id } = await params;
- redirect(`/dashboard/${search_space_id}/new-chat`);
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout.tsx
deleted file mode 100644
index 330158da7..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type React from "react";
-import { use } from "react";
-import { SearchSpaceSettingsLayoutShell } from "./layout-shell";
-
-export default function SearchSpaceSettingsLayout({
- params,
- children,
-}: {
- params: Promise<{ search_space_id: string }>;
- children: React.ReactNode;
-}) {
- const { search_space_id } = use(params);
-
- return (
-
- {children}
-
- );
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/page.tsx
deleted file mode 100644
index 27c59328b..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/page.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default async function SearchSpaceSettingsPage({
- params,
-}: {
- params: Promise<{ search_space_id: string }>;
-}) {
- const { search_space_id } = await params;
- redirect(`/dashboard/${search_space_id}/search-space-settings/general`);
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/prompts/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/prompts/page.tsx
deleted file mode 100644
index cc837299d..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/prompts/page.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
-
-export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
- const { search_space_id } = await params;
- return
;
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-roles/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-roles/page.tsx
deleted file mode 100644
index a343eaacb..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/team-roles/page.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { RolesManager } from "@/components/settings/roles-manager";
-
-export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
- const { search_space_id } = await params;
- return
;
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
deleted file mode 100644
index c75eaf4e4..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { TeamContent } from "./team-content";
-
-export default async function TeamPage({
- params,
-}: {
- params: Promise<{ search_space_id: string }>;
-}) {
- const { search_space_id } = await params;
-
- return (
-
-
-
- );
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/agent-status/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/agent-status/page.tsx
deleted file mode 100644
index dc5c61d2a..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/agent-status/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { AgentStatusContent } from "../components/AgentStatusContent";
-
-export default function Page() {
- return
;
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent.tsx
deleted file mode 100644
index bc31dffed..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent.tsx
+++ /dev/null
@@ -1,321 +0,0 @@
-"use client";
-
-import { useAtomValue } from "jotai";
-import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
-import { Fragment, useMemo } from "react";
-import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Badge } from "@/components/ui/badge";
-import { Separator } from "@/components/ui/separator";
-import { Skeleton } from "@/components/ui/skeleton";
-import type { AgentFeatureFlags } from "@/lib/apis/agent-flags-api.service";
-import { cn } from "@/lib/utils";
-
-type FlagKey = keyof AgentFeatureFlags;
-
-interface FlagDef {
- key: FlagKey;
- label: string;
- description: string;
- envVar: string;
-}
-
-interface FlagGroup {
- id: string;
- title: string;
- subtitle: string;
- flags: FlagDef[];
-}
-
-const FLAG_GROUPS: FlagGroup[] = [
- {
- id: "tier1",
- title: "Tier 1 — Agent quality",
- subtitle: "Context editing, retries, fallbacks, doom-loop, tool-call repair.",
- flags: [
- {
- key: "enable_context_editing",
- label: "Context editing",
- description: "Trim tool outputs and spill old text into backend storage.",
- envVar: "SURFSENSE_ENABLE_CONTEXT_EDITING",
- },
- {
- key: "enable_compaction_v2",
- label: "Compaction v2",
- description: "SurfSense-aware compaction replacing safe summarization.",
- envVar: "SURFSENSE_ENABLE_COMPACTION_V2",
- },
- {
- key: "enable_retry_after",
- label: "Retry-After",
- description: "Honour rate-limit retry-after headers automatically.",
- envVar: "SURFSENSE_ENABLE_RETRY_AFTER",
- },
- {
- key: "enable_model_fallback",
- label: "Model fallback",
- description: "Fail over to a backup model on persistent errors.",
- envVar: "SURFSENSE_ENABLE_MODEL_FALLBACK",
- },
- {
- key: "enable_model_call_limit",
- label: "Model call limit",
- description: "Cap total model calls per turn to prevent budget run-aways.",
- envVar: "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
- },
- {
- key: "enable_tool_call_limit",
- label: "Tool call limit",
- description: "Cap total tool calls per turn.",
- envVar: "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
- },
- {
- key: "enable_tool_call_repair",
- label: "Tool-call name repair",
- description: "Recover from lower-cased / fuzzy tool names emitted by smaller models.",
- envVar: "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
- },
- {
- key: "enable_doom_loop",
- label: "Doom-loop detection",
- description: "Detect repeated identical tool calls and ask the user to confirm.",
- envVar: "SURFSENSE_ENABLE_DOOM_LOOP",
- },
- ],
- },
- {
- id: "tier2",
- title: "Tier 2 — Safety",
- subtitle: "Permission rules, busy-mutex, smarter tool selection.",
- flags: [
- {
- key: "enable_permission",
- label: "Permission middleware",
- description: "Apply allow/deny/ask rules from the Agent Permissions tab.",
- envVar: "SURFSENSE_ENABLE_PERMISSION",
- },
- {
- key: "enable_busy_mutex",
- label: "Busy mutex",
- description: "Prevent two concurrent runs from corrupting the same thread.",
- envVar: "SURFSENSE_ENABLE_BUSY_MUTEX",
- },
- {
- key: "enable_llm_tool_selector",
- label: "LLM tool selector",
- description: "Use a smaller model to pre-filter the tool list per turn.",
- envVar: "SURFSENSE_ENABLE_LLM_TOOL_SELECTOR",
- },
- ],
- },
- {
- id: "tier4",
- title: "Tier 4 — Skills + subagents",
- subtitle: "Built-in skills, specialized subagents, KB planner runnable.",
- flags: [
- {
- key: "enable_skills",
- label: "Skills",
- description: "Load on-demand skill packs (kb-research, report-writing, …).",
- envVar: "SURFSENSE_ENABLE_SKILLS",
- },
- {
- key: "enable_specialized_subagents",
- label: "Specialized subagents",
- description: "Spin up explore / report_writer / connector_negotiator subagents.",
- envVar: "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
- },
- ],
- },
- {
- id: "tier5",
- title: "Tier 5 — Audit + revert",
- subtitle: "Action log + revert route used by the Agent Actions dialog.",
- flags: [
- {
- key: "enable_action_log",
- label: "Action log",
- description: "Persist every tool call to agent_action_log.",
- envVar: "SURFSENSE_ENABLE_ACTION_LOG",
- },
- {
- key: "enable_revert_route",
- label: "Revert route",
- description: "Allow reverting reversible actions from the action log.",
- envVar: "SURFSENSE_ENABLE_REVERT_ROUTE",
- },
- ],
- },
- {
- id: "tier6",
- title: "Tier 6 — Plugins",
- subtitle: "Optional middleware loaded from entry points.",
- flags: [
- {
- key: "enable_plugin_loader",
- label: "Plugin loader",
- description: "Load surfsense.plugins entry-point middleware.",
- envVar: "SURFSENSE_ENABLE_PLUGIN_LOADER",
- },
- ],
- },
- {
- id: "obs",
- title: "Observability",
- subtitle: "Telemetry pipelines (orthogonal to feature gating).",
- flags: [
- {
- key: "enable_otel",
- label: "OpenTelemetry",
- description: "Emit OTel spans (also requires OTEL_EXPORTER_OTLP_ENDPOINT).",
- envVar: "SURFSENSE_ENABLE_OTEL",
- },
- ],
- },
- {
- id: "desktop",
- title: "Desktop",
- subtitle: "Desktop-only capabilities exposed by the backend deployment.",
- flags: [
- {
- key: "enable_desktop_local_filesystem",
- label: "Local filesystem",
- description: "Allow Desktop chat sessions to operate directly on selected local folders.",
- envVar: "ENABLE_DESKTOP_LOCAL_FILESYSTEM",
- },
- ],
- },
-];
-
-function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
- return (
-
-
-
- {def.label}
-
- {def.envVar}
-
-
-
{def.description}
-
-
- {value ? : }
- {value ? "On" : "Off"}
-
-
- );
-}
-
-export function AgentStatusContent() {
- const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
-
- const enabledCount = useMemo(() => {
- if (!flags) return 0;
- return Object.entries(flags).filter(([k, v]) => k !== "disable_new_agent_stack" && v === true)
- .length;
- }, [flags]);
-
- if (isLoading) {
- return (
-
-
-
-
-
- );
- }
-
- if (isError || !flags) {
- return (
-
-
- Failed to load agent status
-
- {error instanceof Error ? error.message : "Unknown error."}
-
-
- );
- }
-
- const masterOff = flags.disable_new_agent_stack;
-
- return (
-
- {masterOff ? (
-
-
- Master kill-switch is on
-
-
- Showing that{" "}
-
- SURFSENSE_DISABLE_NEW_AGENT_STACK=true
-
- , which forces every new middleware off, regardless of the individual flags below.
- Restart the backend after changing it.
-
-
-
- ) : (
-
-
-
- Agent stack
-
- {enabledCount} on
-
-
-
-
- Showing a read-only mirror of the backend's AgentFeatureFlags. Flip an
- env var and restart the backend to change a value.
-
-
-
- )}
-
- {FLAG_GROUPS.map((group, groupIdx) => {
- const allOff = group.flags.every((f) => !flags[f.key]);
- return (
-
- {groupIdx > 0 &&
}
-
-
-
-
{group.title}
-
{group.subtitle}
-
- {allOff && (
-
- all off
-
- )}
-
-
-
- {group.flags.map((def, flagIdx) => (
-
- {flagIdx > 0 && }
-
-
- ))}
-
-
-
- );
- })}
-
- );
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx
deleted file mode 100644
index dde643142..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default async function UserSettingsPage({
- params,
-}: {
- params: Promise<{ search_space_id: string }>;
-}) {
- const { search_space_id } = await params;
- redirect(`/dashboard/${search_space_id}/user-settings/profile`);
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx
similarity index 63%
rename from surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx
index 8f8109156..a9fdb5843 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx
@@ -5,7 +5,7 @@ import { ArtifactsLibrary } from "@/features/artifacts-library";
export default function ArtifactsPage() {
const params = useParams();
- const searchSpaceId = Number(params.search_space_id);
+ const workspaceId = Number(params.workspace_id);
- return
;
+ return
;
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx
similarity index 90%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx
index 4085d47a8..acf816629 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx
@@ -10,7 +10,7 @@ import { AutomationRunsSection } from "./components/automation-runs-section";
import { AutomationTriggersSection } from "./components/automation-triggers-section";
interface AutomationDetailContentProps {
- searchSpaceId: number;
+ workspaceId: number;
automationId: number;
}
@@ -28,7 +28,7 @@ interface AutomationDetailContentProps {
* so the orchestrator stays thin.
*/
export function AutomationDetailContent({
- searchSpaceId,
+ workspaceId,
automationId,
}: AutomationDetailContentProps) {
const perms = useAutomationPermissions();
@@ -45,14 +45,14 @@ export function AutomationDetailContent({
Access denied
- You don't have permission to view automations in this search space.
+ You don't have permission to view automations in this workspace.
);
}
if (!validId) {
- return ;
+ return ;
}
if (isLoading) {
@@ -60,14 +60,14 @@ export function AutomationDetailContent({
}
if (error || !automation) {
- return ;
+ return ;
}
return (
<>
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-definition-section.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-definition-section.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx
similarity index 93%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx
index 71730baeb..945630ba2 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx
@@ -12,7 +12,7 @@ import { DeleteAutomationDialog } from "../../components/delete-automation-dialo
interface AutomationDetailHeaderProps {
automation: Automation;
- searchSpaceId: number;
+ workspaceId: number;
canUpdate: boolean;
canDelete: boolean;
}
@@ -28,7 +28,7 @@ interface AutomationDetailHeaderProps {
*/
export function AutomationDetailHeader({
automation,
- searchSpaceId,
+ workspaceId,
canUpdate,
canDelete,
}: AutomationDetailHeaderProps) {
@@ -44,8 +44,8 @@ export function AutomationDetailHeader({
const PauseIcon = automation.status === "active" ? Pause : Play;
const handleDeleted = useCallback(() => {
- router.push(`/dashboard/${searchSpaceId}/automations`);
- }, [router, searchSpaceId]);
+ router.push(`/dashboard/${workspaceId}/automations`);
+ }, [router, workspaceId]);
async function handleTogglePause() {
await updateAutomation({
@@ -59,7 +59,7 @@ export function AutomationDetailHeader({
@@ -86,7 +86,7 @@ export function AutomationDetailHeader({
size="sm"
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
>
-
+
Edit
@@ -141,7 +141,7 @@ export function AutomationDetailHeader({
onOpenChange={setDeleteOpen}
automationId={automation.id}
automationName={automation.name}
- searchSpaceId={searchSpaceId}
+ workspaceId={workspaceId}
onDeleted={handleDeleted}
/>
)}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-loading.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-loading.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-loading.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx
similarity index 86%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx
index 1681caf25..23fe823fe 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx
@@ -4,7 +4,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationNotFoundProps {
- searchSpaceId: number;
+ workspaceId: number;
error?: Error | null;
}
@@ -14,7 +14,7 @@ interface AutomationNotFoundProps {
* UI on purpose — leaking that an id exists you can't read is worse than
* a vague message.
*/
-export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundProps) {
+export function AutomationNotFound({ workspaceId, error }: AutomationNotFoundProps) {
return (
@@ -24,7 +24,7 @@ export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundP
{error?.message ? ` (${error.message})` : null}
-
+
Back to automations
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-runs-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-runs-section.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-runs-section.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-runs-section.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-triggers-section.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-triggers-section.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/execution-summary.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/execution-summary.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/plan-step-card.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/plan-step-card.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-details-panel.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-details-panel.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-row.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-row.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-status-badge.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-status-badge.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-status-badge.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-status-badge.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-step-result-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-step-result-card.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-step-result-card.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-step-result-card.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/runs-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/runs-loading.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/runs-loading.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/runs-loading.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/trigger-card.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/trigger-card.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx
similarity index 81%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx
index c05bff7d9..470cf7d5c 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx
@@ -8,7 +8,7 @@ import { AutomationNotFound } from "../components/automation-not-found";
import { AutomationEditHeader } from "./components/automation-edit-header";
interface AutomationEditContentProps {
- searchSpaceId: number;
+ workspaceId: number;
automationId: number;
}
@@ -17,7 +17,7 @@ interface AutomationEditContentProps {
* structure but gates on ``canUpdate`` instead of ``canRead``: a user who
* can read but not update is bounced to the access-denied panel.
*/
-export function AutomationEditContent({ searchSpaceId, automationId }: AutomationEditContentProps) {
+export function AutomationEditContent({ workspaceId, automationId }: AutomationEditContentProps) {
const perms = useAutomationPermissions();
const validId = Number.isInteger(automationId) && automationId > 0;
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
@@ -32,14 +32,14 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
Access denied
- You don't have permission to edit automations in this search space.
+ You don't have permission to edit automations in this workspace.
);
}
if (!validId) {
- return ;
+ return ;
}
if (isLoading) {
@@ -47,18 +47,18 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
}
if (error || !automation) {
- return ;
+ return ;
}
return (
(
)}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
similarity index 89%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
index ca477220e..6951803df 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
@@ -7,16 +7,16 @@ import type { Automation } from "@/contracts/types/automation.types";
interface AutomationEditHeaderProps {
automation: Automation;
- searchSpaceId: number;
+ workspaceId: number;
modeSwitcher?: ReactNode;
}
export function AutomationEditHeader({
automation,
- searchSpaceId,
+ workspaceId,
modeSwitcher,
}: AutomationEditHeaderProps) {
- const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
+ const detailHref = `/dashboard/${workspaceId}/automations/${automation.id}`;
return (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/page.tsx
similarity index 61%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/page.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/page.tsx
index 8477b9e12..728e018d0 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/page.tsx
@@ -3,14 +3,14 @@ import { AutomationEditContent } from "./automation-edit-content";
export default async function AutomationEditPage({
params,
}: {
- params: Promise<{ search_space_id: string; automation_id: string }>;
+ params: Promise<{ workspace_id: string; automation_id: string }>;
}) {
- const { search_space_id, automation_id } = await params;
+ const { workspace_id, automation_id } = await params;
return (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx
similarity index 62%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx
index dbaceecdd..8f6024a7b 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx
@@ -3,14 +3,14 @@ import { AutomationDetailContent } from "./automation-detail-content";
export default async function AutomationDetailPage({
params,
}: {
- params: Promise<{ search_space_id: string; automation_id: string }>;
+ params: Promise<{ workspace_id: string; automation_id: string }>;
}) {
- const { search_space_id, automation_id } = await params;
+ const { workspace_id, automation_id } = await params;
return (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx
similarity index 79%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx
index d9c949058..826337a27 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx
@@ -8,19 +8,19 @@ import { AutomationsTable } from "./components/automations-table";
import { useAutomationPermissions } from "./hooks/use-automation-permissions";
interface AutomationsContentProps {
- searchSpaceId: number;
+ workspaceId: number;
}
/**
* Client orchestrator for the automations list page. Pulls the active
- * search space's first page (via ``useAutomations`` → ``automationsListAtom``)
+ * workspace's first page (via ``useAutomations`` → ``automationsListAtom``)
* and the user's permissions, then decides between empty / loading / table.
*
* Read access is mandatory; anything else is hidden behind RBAC. The
* permissions hook is co-located in this slice so adding/removing
* surfaces is a one-file change.
*/
-export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
+export function AutomationsContent({ workspaceId }: AutomationsContentProps) {
const { automations, total, loading, error } = useAutomations();
const perms = useAutomationPermissions();
@@ -28,10 +28,10 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
// Permissions gate the entire page; defer everything until we know.
return (
<>
-
+
Access denied
- You don't have permission to view automations in this search space.
+ You don't have permission to view automations in this workspace.
);
@@ -56,7 +56,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
return (
<>
-
+
>
);
}
@@ -87,14 +87,14 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
return (
<>
)}
>
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row.tsx
similarity index 92%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row.tsx
index 74c95cee4..a7c831ed1 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row.tsx
@@ -8,7 +8,7 @@ import { AutomationStatusBadge } from "./automation-status-badge";
interface AutomationRowProps {
automation: AutomationSummary;
- searchSpaceId: number;
+ workspaceId: number;
canUpdate: boolean;
canDelete: boolean;
}
@@ -21,7 +21,7 @@ interface AutomationRowProps {
*/
export function AutomationRow({
automation,
- searchSpaceId,
+ workspaceId,
canUpdate,
canDelete,
}: AutomationRowProps) {
@@ -29,7 +29,7 @@ export function AutomationRow({
{automation.name}
@@ -45,7 +45,7 @@ export function AutomationRow({
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-status-badge.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-status-badge.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-triggers-summary.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-triggers-summary.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-empty-state.tsx
similarity index 80%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-empty-state.tsx
index 1ee71c636..1004c21b6 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-empty-state.tsx
@@ -4,7 +4,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationsEmptyStateProps {
- searchSpaceId: number;
+ workspaceId: number;
canCreate: boolean;
}
@@ -14,7 +14,7 @@ interface AutomationsEmptyStateProps {
* "new automation" form. We surface the chat path explicitly so users
* don't go hunting for an "add" button that doesn't exist.
*/
-export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
+export function AutomationsEmptyState({ workspaceId, canCreate }: AutomationsEmptyStateProps) {
return (
@@ -28,19 +28,19 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
{canCreate ? (
- Create via chat
+ Create via chat
- Create manually
+ Create manually
) : (
- You don't have permission to create automations in this search space.
+ You don't have permission to create automations in this workspace.
)}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-header.tsx
similarity index 88%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-header.tsx
index 5c1fcb507..6e284709a 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-header.tsx
@@ -3,7 +3,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationsHeaderProps {
- searchSpaceId: number;
+ workspaceId: number;
total: number;
loading: boolean;
canCreate: boolean;
@@ -22,7 +22,7 @@ interface AutomationsHeaderProps {
* handled per-automation in the builder + approval card, not gated here.
*/
export function AutomationsHeader({
- searchSpaceId,
+ workspaceId,
total,
loading,
canCreate,
@@ -46,10 +46,10 @@ export function AutomationsHeader({
variant="ghost"
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
>
-
Create manually
+
Create manually
- Create via chat
+ Create via chat
)}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-table.tsx
similarity index 96%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-table.tsx
index 74c604173..09a1156a1 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-table.tsx
@@ -7,7 +7,7 @@ import { AutomationsLoadingRows } from "./automations-loading";
interface AutomationsTableProps {
automations: AutomationSummary[];
- searchSpaceId: number;
+ workspaceId: number;
loading: boolean;
canUpdate: boolean;
canDelete: boolean;
@@ -19,7 +19,7 @@ interface AutomationsTableProps {
*/
export function AutomationsTable({
automations,
- searchSpaceId,
+ workspaceId,
loading,
canUpdate,
canDelete,
@@ -60,7 +60,7 @@ export function AutomationsTable({
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/advanced-section.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/advanced-section.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx
similarity index 96%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx
index a68e53a1c..0994ceb7d 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx
@@ -49,7 +49,7 @@ import { UnattendedToggle } from "./unattended-toggle";
interface AutomationBuilderFormProps {
mode: "create" | "edit";
- searchSpaceId: number;
+ workspaceId: number;
/** Required in edit mode; seeds the form and trigger reconciliation. */
automation?: Automation;
/**
@@ -78,7 +78,7 @@ function mapFormErrors(error: z.ZodError): Record
{
export function AutomationBuilderForm({
mode,
- searchSpaceId,
+ workspaceId,
automation,
submitDisabledReason,
renderModeSwitcher,
@@ -120,7 +120,7 @@ export function AutomationBuilderForm({
const [submitting, setSubmitting] = useState(false);
- // Eligible models + the search-space-seeded defaults. Models are chosen per
+ // Eligible models + the workspace-seeded defaults. Models are chosen per
// automation on create; in edit mode the backend preserves the captured
// snapshot, so the picker is create-only.
const eligibleModels = useAutomationEligibleModels();
@@ -156,10 +156,7 @@ export function AutomationBuilderForm({
if (mode === "edit" && automation) {
return { ...buildUpdatePayload(formForPayload), status: automation.status };
}
- const { search_space_id: _ignored, ...rest } = buildCreatePayload(
- formForPayload,
- searchSpaceId
- );
+ const { workspace_id: _ignored, ...rest } = buildCreatePayload(formForPayload, workspaceId);
return rest;
}
@@ -265,16 +262,16 @@ export function AutomationBuilderForm({
}
await updateAutomation({ automationId: automation.id, patch: parsed.data });
await reconcileTriggers(automation.id);
- router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
+ router.push(`/dashboard/${workspaceId}/automations/${automation.id}`);
} else {
- const payload = buildCreatePayload(formForPayload, searchSpaceId);
+ const payload = buildCreatePayload(formForPayload, workspaceId);
const parsed = automationCreateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
return;
}
const created = await createAutomation(parsed.data);
- router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
+ router.push(`/dashboard/${workspaceId}/automations/${created.id}`);
}
} catch (err) {
setRootError((err as Error).message ?? "Submit failed");
@@ -294,18 +291,18 @@ export function AutomationBuilderForm({
return;
}
await updateAutomation({ automationId: automation.id, patch: parsed.data });
- router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
+ router.push(`/dashboard/${workspaceId}/automations/${automation.id}`);
} else {
const parsed = automationCreateRequest.safeParse({
...jsonValue,
- search_space_id: searchSpaceId,
+ workspace_id: workspaceId,
});
if (!parsed.success) {
setJsonIssues(zodIssueList(parsed.error));
return;
}
const created = await createAutomation(parsed.data);
- router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
+ router.push(`/dashboard/${workspaceId}/automations/${created.id}`);
}
} catch (err) {
setJsonIssues([(err as Error).message ?? "Submit failed"]);
@@ -396,7 +393,7 @@ export function AutomationBuilderForm({
patchForm({ tasks })}
/>
patchForm({ models: { ...form.models, ...patch } })}
/>
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields.tsx
similarity index 97%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields.tsx
index 6dd42366b..429b8d37f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields.tsx
@@ -34,7 +34,7 @@ interface AutomationModelFieldsProps {
/** Resolved (effective) ids — never `0` once defaults are seeded. */
value: AutomationModelSelection;
onChange: (patch: Partial) => void;
- searchSpaceId: number;
+ workspaceId: number;
errors?: Partial>;
}
@@ -47,11 +47,11 @@ interface AutomationModelFieldsProps {
export function AutomationModelFields({
value,
onChange,
- searchSpaceId,
+ workspaceId,
errors,
}: AutomationModelFieldsProps) {
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
- const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/models`;
+ const rolesHref = `/dashboard/${workspaceId}/workspace-settings/models`;
return (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/basics-section.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/basics-section.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/builder-summary.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/builder-summary.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/form-field.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/form-field.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/form-field.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/form-field.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/json-mode-panel.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/json-mode-panel.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx
similarity index 97%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx
index c0651a90b..d17b5f9a6 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx
@@ -20,7 +20,7 @@ import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
interface MentionTaskInputProps {
- searchSpaceId: number;
+ workspaceId: number;
value: string;
mentions: MentionedDocumentInfo[];
onChange: (text: string, mentions: MentionedDocumentInfo[]) => void;
@@ -73,6 +73,9 @@ function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
if (mention.kind === "folder") {
return { id: mention.id, title: mention.title, kind: "folder" };
}
+ if (mention.kind === "thread") {
+ return { id: mention.id, title: mention.title, kind: "thread" };
+ }
return {
id: mention.id,
title: mention.title,
@@ -95,7 +98,7 @@ function removeFirstToken(text: string, token: string): string {
* so the builder can persist IDs for the run.
*/
export function MentionTaskInput({
- searchSpaceId,
+ workspaceId,
value,
mentions,
onChange,
@@ -232,7 +235,7 @@ export function MentionTaskInput({
) => void;
onMoveUp: () => void;
@@ -31,7 +31,7 @@ export function TaskItem({
index,
total,
task,
- searchSpaceId,
+ workspaceId,
error,
onChange,
onMoveUp,
@@ -89,7 +89,7 @@ export function TaskItem({
hint="Type @ to reference files, folders, or connectors for extra context."
>
;
- searchSpaceId: number;
+ workspaceId: number;
onChange: (tasks: BuilderTask[]) => void;
}
@@ -15,7 +15,7 @@ interface TaskListProps {
* Ordered list of agent tasks. Steps run sequentially in the order shown.
* Reordering is done with up/down buttons to avoid a drag-and-drop dependency.
*/
-export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListProps) {
+export function TaskList({ tasks, errors, workspaceId, onChange }: TaskListProps) {
function updateAt(index: number, patch: Partial) {
onChange(tasks.map((task, i) => (i === index ? { ...task, ...patch } : task)));
}
@@ -40,7 +40,7 @@ export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListPro
index={index}
total={tasks.length}
task={task}
- searchSpaceId={searchSpaceId}
+ workspaceId={workspaceId}
error={errors[`tasks.${index}.query`]}
onChange={(patch) => updateAt(index, patch)}
onMoveUp={() => move(index, -1)}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/timezone-combobox.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/timezone-combobox.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/unattended-toggle.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/unattended-toggle.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/delete-automation-dialog.tsx
similarity index 95%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/components/delete-automation-dialog.tsx
index 23fc522ca..9208832be 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/delete-automation-dialog.tsx
@@ -19,7 +19,7 @@ interface DeleteAutomationDialogProps {
onOpenChange: (open: boolean) => void;
automationId: number;
automationName: string;
- searchSpaceId: number;
+ workspaceId: number;
/**
* Fired after a successful delete, before the dialog closes. The detail
* page uses this to navigate back to the list (the row simply vanishes
@@ -38,7 +38,7 @@ export function DeleteAutomationDialog({
onOpenChange,
automationId,
automationName,
- searchSpaceId,
+ workspaceId,
onDeleted,
}: DeleteAutomationDialogProps) {
const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
@@ -47,7 +47,7 @@ export function DeleteAutomationDialog({
async function handleConfirm() {
setSubmitting(true);
try {
- await deleteAutomation({ automationId, searchSpaceId });
+ await deleteAutomation({ automationId, workspaceId: workspaceId });
onDeleted?.();
onOpenChange(false);
} finally {
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/hooks/use-automation-permissions.ts b/surfsense_web/app/dashboard/[workspace_id]/automations/hooks/use-automation-permissions.ts
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/hooks/use-automation-permissions.ts
rename to surfsense_web/app/dashboard/[workspace_id]/automations/hooks/use-automation-permissions.ts
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/new/automation-new-content.tsx
similarity index 83%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/new/automation-new-content.tsx
index cdf5761b1..dd541923b 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/new/automation-new-content.tsx
@@ -5,7 +5,7 @@ import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationNewHeader } from "./components/automation-new-header";
interface AutomationNewContentProps {
- searchSpaceId: number;
+ workspaceId: number;
}
/**
@@ -18,7 +18,7 @@ interface AutomationNewContentProps {
* list eligible (premium/BYOK) models, surface a per-slot notice when none
* exist, and block submit until each slot resolves.
*/
-export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
+export function AutomationNewContent({ workspaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions();
if (perms.loading) {
@@ -31,7 +31,7 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
Access denied
- You don't have permission to create automations in this search space.
+ You don't have permission to create automations in this workspace.
);
@@ -40,9 +40,9 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
return (
(
-
+
)}
/>
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/new/components/automation-new-header.tsx
similarity index 87%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/new/components/automation-new-header.tsx
index a2f7f85f0..57aeffd8a 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/new/components/automation-new-header.tsx
@@ -5,17 +5,17 @@ import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
interface AutomationNewHeaderProps {
- searchSpaceId: number;
+ workspaceId: number;
modeSwitcher?: ReactNode;
}
-export function AutomationNewHeader({ searchSpaceId, modeSwitcher }: AutomationNewHeaderProps) {
+export function AutomationNewHeader({ workspaceId, modeSwitcher }: AutomationNewHeaderProps) {
return (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/new/page.tsx
similarity index 55%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/new/page.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/new/page.tsx
index f6e8e0008..a9c23187e 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/new/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/new/page.tsx
@@ -3,13 +3,13 @@ import { AutomationNewContent } from "./automation-new-content";
export default async function NewAutomationPage({
params,
}: {
- params: Promise<{ search_space_id: string }>;
+ params: Promise<{ workspace_id: string }>;
}) {
- const { search_space_id } = await params;
+ const { workspace_id } = await params;
return (
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/page.tsx
similarity index 57%
rename from surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/automations/page.tsx
index 0502d2310..4e4d5c055 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/page.tsx
@@ -3,13 +3,13 @@ import { AutomationsContent } from "./automations-content";
export default async function AutomationsPage({
params,
}: {
- params: Promise<{ search_space_id: string }>;
+ params: Promise<{ workspace_id: string }>;
}) {
- const { search_space_id } = await params;
+ const { workspace_id } = await params;
return (
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/buy-more/page.tsx
similarity index 100%
rename from surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/buy-more/page.tsx
diff --git a/surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/buy-pages/page.tsx
similarity index 63%
rename from surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/buy-pages/page.tsx
index f2439da44..04b0624aa 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/buy-pages/page.tsx
@@ -6,11 +6,11 @@ import { useEffect } from "react";
export default function BuyPagesPage() {
const router = useRouter();
const params = useParams();
- const searchSpaceId = params?.search_space_id ?? "";
+ const workspaceId = params?.workspace_id ?? "";
useEffect(() => {
- router.replace(`/dashboard/${searchSpaceId}/buy-more`);
- }, [router, searchSpaceId]);
+ router.replace(`/dashboard/${workspaceId}/buy-more`);
+ }, [router, workspaceId]);
return null;
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/buy-tokens/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/buy-tokens/page.tsx
similarity index 63%
rename from surfsense_web/app/dashboard/[search_space_id]/buy-tokens/page.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/buy-tokens/page.tsx
index cfdbd3938..d2b6b8750 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/buy-tokens/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/buy-tokens/page.tsx
@@ -6,11 +6,11 @@ import { useEffect } from "react";
export default function BuyTokensPage() {
const router = useRouter();
const params = useParams();
- const searchSpaceId = params?.search_space_id ?? "";
+ const workspaceId = params?.workspace_id ?? "";
useEffect(() => {
- router.replace(`/dashboard/${searchSpaceId}/buy-more`);
- }, [router, searchSpaceId]);
+ router.replace(`/dashboard/${workspaceId}/buy-more`);
+ }, [router, workspaceId]);
return null;
}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx
new file mode 100644
index 000000000..79a7b2ffc
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx
@@ -0,0 +1,11 @@
+import { AllChatsWorkspaceContent } from "@/components/layout/ui/sidebar";
+
+export default async function ChatsPage({ params }: { params: Promise<{ workspace_id: string }> }) {
+ const { workspace_id } = await params;
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx
similarity index 81%
rename from surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
rename to surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx
index c3eeb2bf6..1f4948548 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx
@@ -13,7 +13,8 @@ import {
modelConnectionsAtom,
modelRolesAtom,
} from "@/atoms/model-connections/model-connections-query.atoms";
-import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
+import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
+import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
@@ -25,17 +26,17 @@ import { isLlmOnboardingComplete } from "@/lib/onboarding";
export function DashboardClientLayout({
children,
- searchSpaceId,
+ workspaceId,
}: {
children: React.ReactNode;
- searchSpaceId: string;
+ workspaceId: string;
}) {
const t = useTranslations("dashboard");
const router = useRouter();
const pathname = usePathname();
- const { search_space_id } = useParams();
- const activeSearchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
- const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
+ const { workspace_id } = useParams();
+ const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
+ const setActiveWorkspaceIdState = useSetAtom(activeWorkspaceIdAtom);
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
const { data: modelRoles = {}, isLoading: loading, error } = useAtomValue(modelRolesAtom);
@@ -52,12 +53,12 @@ export function DashboardClientLayout({
const isOnboardingPage = pathname?.includes("/onboard");
const isOwner = access?.is_owner ?? false;
- const isSearchSpaceReady = activeSearchSpaceId === searchSpaceId;
+ const isWorkspaceReady = activeWorkspaceId === workspaceId;
useEffect(() => {
- if (isSearchSpaceReady) return;
+ if (isWorkspaceReady) return;
setHasCheckedOnboarding(false);
- }, [isSearchSpaceReady]);
+ }, [isWorkspaceReady]);
useEffect(() => {
if (isOnboardingPage) {
@@ -66,7 +67,7 @@ export function DashboardClientLayout({
}
if (
- isSearchSpaceReady &&
+ isWorkspaceReady &&
!loading &&
!accessLoading &&
!globalConfigsLoading &&
@@ -75,7 +76,7 @@ export function DashboardClientLayout({
!hasCheckedOnboarding
) {
// Onboarding is only relevant when no operator-provided
- // global_llm_config.yaml exists. When it does, search spaces inherit
+ // global_llm_config.yaml exists. When it does, workspaces inherit
// the global config and should never be forced into onboarding.
if (globalConfigStatus?.exists) {
setHasCheckedOnboarding(true);
@@ -98,11 +99,11 @@ export function DashboardClientLayout({
return;
}
- router.push(`/dashboard/${searchSpaceId}/onboard`);
+ router.push(`/dashboard/${workspaceId}/onboard`);
setHasCheckedOnboarding(true);
}
}, [
- isSearchSpaceReady,
+ isWorkspaceReady,
loading,
accessLoading,
globalConfigsLoading,
@@ -115,7 +116,7 @@ export function DashboardClientLayout({
isOnboardingPage,
isOwner,
router,
- searchSpaceId,
+ workspaceId,
hasCheckedOnboarding,
]);
@@ -144,32 +145,32 @@ export function DashboardClientLayout({
useEffect(() => {
const activeSeacrhSpaceId =
- typeof search_space_id === "string"
- ? search_space_id
- : Array.isArray(search_space_id) && search_space_id.length > 0
- ? search_space_id[0]
+ typeof workspace_id === "string"
+ ? workspace_id
+ : Array.isArray(workspace_id) && workspace_id.length > 0
+ ? workspace_id[0]
: "";
if (!activeSeacrhSpaceId) return;
- setActiveSearchSpaceIdState(activeSeacrhSpaceId);
+ setActiveWorkspaceIdState(activeSeacrhSpaceId);
// Sync to Electron store if stored value is null (first navigation)
- if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
- const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
+ if (electronAPI?.getActiveWorkspace && electronAPI.setActiveWorkspace) {
+ const setActiveWorkspace = electronAPI.setActiveWorkspace;
electronAPI
- .getActiveSearchSpace()
+ .getActiveWorkspace()
.then((stored: string | null) => {
if (!stored) {
- setActiveSearchSpace(activeSeacrhSpaceId);
+ setActiveWorkspace(activeSeacrhSpaceId);
}
})
.catch(() => {});
}
- }, [search_space_id, setActiveSearchSpaceIdState, electronAPI]);
+ }, [workspace_id, setActiveWorkspaceIdState, electronAPI]);
// Determine if we should show loading
const shouldShowLoading =
!hasCheckedOnboarding &&
- (!isSearchSpaceReady ||
+ (!isWorkspaceReady ||
loading ||
accessLoading ||
globalConfigsLoading ||
@@ -214,7 +215,10 @@ export function DashboardClientLayout({
return (
- {children}
+
+ {children}
+