diff --git a/plans/backend/01-rename-db.md b/plans/backend/01-rename-db.md index d1417c974..2f6e77401 100644 --- a/plans/backend/01-rename-db.md +++ b/plans/backend/01-rename-db.md @@ -2,6 +2,10 @@ Part of [00-umbrella-plan.md](00-umbrella-plan.md), Phase 1. Backend only (`surfsense_backend`). +> **Status: SHIPPED** · as of 2026-06-27 · branch `feat/rename-searchspace-to-workspace` · PR [#1546](https://github.com/MODSetter/SurfSense/pull/1546) +> Last commit of this phase: `49d3001fb` (DB rename through migration 170). Phase 1 + Phase 2 shipped as one atomic PR. +> The sections below are the **original design/rationale**; the as-built state + how to re-verify live in the [Implementation record](#implementation-record-as-built) at the bottom. Ground truth for files/commits is the PR, not this doc. + ## Goal Physically rename the `SearchSpace` schema to `WorkSpace` in PostgreSQL via a single Alembic migration, and update the ORM's physical mapping + the Zero publication so the app keeps booting and running — WITHOUT yet touching the ~250 backend files that reference the `search_space_id` Python attribute (that symbolic rename is Phase 2). @@ -180,3 +184,30 @@ The Alembic migration, the `db.py` edits (shim + constraint `name=` + `_INDEX_DE - Phase 2: rename Python attribute `search_space_id` -> `workspace_id`, class `SearchSpace` -> `Workspace`, Pydantic schemas, API routes/paths (`/searchspaces` + `/search-spaces` -> `/workspaces`), Redis keys, storage path segments; drop the explicit `Column("workspace_id", ...)` shim. - Frontend Zero schema, route segment, i18n — deferred frontend umbrella. + +--- + +## Implementation record (as-built) + +### Deviations from the plan + +- **Migration 168 idempotency fixed up-front**: the plan flagged from-scratch alembic as pre-existing-broken; we made 168 idempotent so the rename starts from a clean head, but did **not** take on the full baseline-squash. From-scratch `alembic upgrade head` therefore stays pre-existing-broken (rev 23 conflict); only the existing-DB `169 -> 170` path is in scope/verified. +- **Cosmetic sequence/PK renames applied** (the plan's confirmed decision): `searchspaces_id_seq -> workspaces_id_seq`, `searchspaces_pkey -> workspaces_pkey`, plus the RBAC tables' `*_id_seq` / `*_pkey`, for a clean final schema. +- Everything else implemented exactly as designed: shim via `Column("workspace_id", ...)`; publication mutated **only** through the blessed `apply_publication` path (no raw DROP/CREATE PUBLICATION, per migration 116); `__table_args__` inner column-reference strings deliberately **left** as `search_space_id` (flipped in Phase 2). + +### Carve-outs as-shipped (Phase 1) + +- `'SEARCH_SPACE'` CHECK literal in `ck_connections_scope_owner` kept; only the `search_space_id` column reference flipped to `workspace_id`. No enum/data migration. + +### Verify current state (re-runnable) + +Each line is a command + the last captured result. Re-run to confirm the *current* truth instead of trusting the snapshot. Schema gates assume a DB at rev `170` (locally `surfsense_oldshape`) with `AUTH_TYPE=LOCAL` (so the env-gated `oauth_account` table isn't in the ORM metadata). + +- **Schema drift (ORM ↔ physical @170)** — + `AUTH_TYPE=LOCAL DATABASE_URL=postgresql+asyncpg://…@localhost:5432/surfsense_oldshape uv run alembic check` + → last (2026-06-27): `No new upgrade operations detected.` (With `AUTH_TYPE=GOOGLE` the only delta is the env-gated `oauth_account` table — unrelated to the rename.) +- **Publication columns** — + `rg -n 'workspace_id|search_space_id' app/zero_publication.py` + → last (2026-06-27): all four lists = `workspace_id`, zero `search_space_id`. + +Validated during the build run (2026-06-26), **not** reproducible on a `create_all`-built snapshot (it has no `zero_publication` object): `python -m app.zero_publication --verify` → verified (interlock-footgun guard, finding 2); `alembic upgrade 170 → downgrade -1 → upgrade 170` round-trip (SQL reversibility; downgrade restores the `search_space_id` publication shape via hardcoded lists, per finding 7). diff --git a/plans/backend/02-rename-backend.md b/plans/backend/02-rename-backend.md index a51f7cc2f..c2ec94869 100644 --- a/plans/backend/02-rename-backend.md +++ b/plans/backend/02-rename-backend.md @@ -2,6 +2,10 @@ Part of [00-umbrella-plan.md](00-umbrella-plan.md), Phase 2. Backend only (`surfsense_backend`). +> **Status: SHIPPED** · as of 2026-06-27 · branch `feat/rename-searchspace-to-workspace` · PR [#1546](https://github.com/MODSetter/SurfSense/pull/1546) +> Last commit of this phase: `902b3374e` (backend code/API rename, Waves A–F + carve-outs). Phase 1 + Phase 2 shipped as one atomic PR. +> The sections below are the **original design/rationale**; the as-built state + how to re-verify live in the [Implementation record](#implementation-record-as-built) at the bottom. Ground truth for files/commits is the PR, not this doc. + ## Goal Remove the Phase 1 shim and complete the SYMBOLIC rename `SearchSpace -> Workspace` / `search_space_id -> workspace_id` across the ~150 backend files, then consolidate the three live URL spellings (`/searchspaces`, `/search-spaces`, `/search-space`) onto a single canonical `/workspaces`. After this phase the physical DB (Phase 1) and the Python/API surface speak the same name. @@ -131,3 +135,54 @@ These do not move with a symbol rename; each is decided here. - Satellite apps (desktop, Obsidian, browser extension, evals) + docs — deferred. - Connector `category` discriminator and `SearchSourceConnector` handling — Phase 4 ([04a-connector-category.md](04a-connector-category.md)). - Enum VALUE migration and observability-key rename — deliberately deferred announced changes. + +--- + +## Implementation record (as-built) + +Whole-PR footprint (vs `ci_mvp`): **452 files, +5317/-4781** — 325 under `app/`, 124 under `tests/`, and **exactly 2** `alembic/versions/` files (`168` idempotency + `170` rename), confirming the immutable-replay-log carve-out (#9) held. + +The plan's Waves A–F were executed in order, as described above; what follows records only what diverged from that design and the evidence that it landed cleanly. + +### Deviations from the plan + +- **Bulk rename executed via scoped Python codemods** (multiple passes), strictly limited to `app/` + `tests/` (never `alembic/versions/`), instead of IDE/`ast-grep`. Same scope rule and same carve-out exclusions as designed. +- **camelCase `searchSpaceId` -> `workspaceId`**: a camelCase payload key (in `app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/middleware.py`) was renamed too — decided internal/transient, so it follows the hard cutover. The plan enumerated snake/Pascal/Title/hyphen variants; this camelCase variant was caught in a follow-up codemod pass (residual `searchSpaceId` in `app/` now = 0). +- All other carve-outs honored exactly as decided (below). + +### Carve-outs as-shipped (verified by residual grep) + +KEPT (intentional): +- Enum VALUES `'SEARCH_SPACE'` (`ConnectionScope`, `ChatVisibility`) — value migration deferred (carve-out 1). +- Celery wire task names `"delete_search_space_background"`, `"ai_sort_search_space"` (carve-out 2). Python functions renamed (`ai_sort_workspace_task`, `delete_workspace_task`); producer and worker agree because dispatch is via `.delay()` on the task object (no hardcoded `send_task("ai_sort_search_space")`). +- OTel/metric key `search_space.id` (carve-out 5) — 3 sites (`observability/otel.py` x2, `observability/metrics.py`); dashboards/alerts depend on it. +- API error code `"SEARCH_SPACE_FORBIDDEN"` (external contract string). +- `SearchSourceConnector` class/table (carve-out 8, unrelated taxonomy). +- Historical `alembic/versions/*` (carve-out 9, immutable replay log). + +RENAMED (accepted transient deploy cost): +- Redis keys `surfsense:spawn_paused:{workspace_id}`, `ai_sort:workspace:{workspace_id}:lock` (carve-out 3) + `.env.example` runbook. +- Event payload key (carve-out 4) and LangGraph state-channel key (carve-out 10) -> `workspace_id`; default seed name -> `"My Workspace"` (carve-out 11). + +### Verify current state (re-runnable) + +Each line is a command + the last captured result. Re-run to confirm the *current* truth instead of trusting the snapshot. Run from `surfsense_backend/`. + +- **Full backend suite** — `uv run pytest tests/unit tests/integration` + → last (2026-06-27): `3016 passed, 1 skipped` in ~101s. The skip is a Windows-only event-loop test (`tests/unit/tasks/test_celery_async_runner.py:302`). +- **Schema drift @170** — `AUTH_TYPE=LOCAL DATABASE_URL=…surfsense_oldshape uv run alembic check` + → last (2026-06-27): `No new upgrade operations detected.` +- **Route hard-cutover** — load `app.app:app` and inspect `{r.path for r in app.routes}` + → last (2026-06-27): legacy `/searchspaces` `/search-spaces` `/search-space` = 0 each; canonical `/workspaces` = 26 (of 278 routes). +- **Residual symbols** — `rg -ni 'search.?space' app` + → last (2026-06-27): 52 hits, all carve-outs (enum `SEARCH_SPACE` values dominate; 3× OTel `search_space.id`; 2× Celery wire names; 1× `SEARCH_SPACE_FORBIDDEN`). No stray attribute/relationship/route residue. +- **`alembic/versions/` immutability** — `git diff --stat ci_mvp..HEAD -- surfsense_backend/alembic/versions` + → last (2026-06-27): only `168` + `170` (guard against carve-out 9). + +### Known caveats at deploy (team-acknowledged) + +- **Zero-cache replica reset** required on deploy (`ZERO_AUTO_RESET`) — consequence of the schema rename. +- **From-scratch `alembic upgrade head` stays pre-existing-broken** (rev 23 conflict); the existing-DB `169 -> 170` path is verified. Separate baseline-squash task. +- **Planned-downtime deploy**: drain Celery/event queues and restart workers before cutover (renamed event payload / LangGraph state-channel / Redis keys). In-flight state across the boundary is discarded by design — acceptable given the downtime window. +- **Live Celery worker not exercised end-to-end** (the broker is mocked in tests). The task code paths are covered by unit/integration tests; a real worker + Redis smoke is the one verification not yet run. +- **Clients are intentionally broken** by the hard cutover until the frontend/satellite umbrellas land — backend correctness is verified via the suite + OpenAPI/route introspection + direct API calls, not the old UI. diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index a1d410eef..6caa33c4e 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -472,7 +472,7 @@ LANGSMITH_PROJECT=surfsense # ----------------------------------------------------------------------------- # Connector discovery TTL cache (Phase 1.4 perf optimization) # ----------------------------------------------------------------------------- -# Caches the per-search-space "available connectors" + "available document +# Caches the per-workspace "available connectors" + "available document # types" lookups that ``create_surfsense_deep_agent`` hits on every turn. # ORM event listeners auto-invalidate on connector / document inserts, # updates and deletes — the TTL only bounds staleness for bulk-import @@ -512,8 +512,8 @@ LANGSMITH_PROJECT=surfsense # Per-workspace spawn-paused kill switch — set via Redis at runtime, not # this env var. The env var below only disables the check itself (useful # for local dev without Redis). To pause a workspace in production: -# redis-cli SET surfsense:spawn_paused: 1 EX 600 -# redis-cli DEL surfsense:spawn_paused: +# redis-cli SET surfsense:spawn_paused: 1 EX 600 +# redis-cli DEL surfsense:spawn_paused: # The check is fail-open: a Redis blip never blocks ``task(...)``. # SURFSENSE_TASK_SPAWN_PAUSED_DISABLED=false diff --git a/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py b/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py index 1e902ea58..75c2725c3 100644 --- a/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py +++ b/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py @@ -6,8 +6,6 @@ Revises: 167 from collections.abc import Sequence -import sqlalchemy as sa - from alembic import op revision: str = "168" @@ -63,9 +61,16 @@ def downgrade() -> None: ) op.execute( """ - UPDATE refresh_tokens - SET is_revoked = TRUE - WHERE revoked_at IS NOT NULL + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'refresh_tokens' + AND column_name = 'revoked_at' + ) THEN + UPDATE refresh_tokens SET is_revoked = TRUE WHERE revoked_at IS NOT NULL; + END IF; + END $$; """ ) op.execute("ALTER TABLE refresh_tokens ALTER COLUMN is_revoked DROP DEFAULT") diff --git a/surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py b/surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py new file mode 100644 index 000000000..2815f9603 --- /dev/null +++ b/surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py @@ -0,0 +1,508 @@ +"""rename searchspace schema to workspace + +Renames the SearchSpace tables/columns/constraints/indexes/sequences to the +workspace_* names the ORM already declares, and reconciles the Zero publication +to match. + +Revision ID: 170 +Revises: 169 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op +from app.zero_publication import apply_publication + +revision: str = "170" +down_revision: str | None = "169" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PUBLICATION_NAME = "zero_publication" + +# Tables whose published column list references the renamed column. Their +# column-list dependency must be removed before RENAME COLUMN is permitted, +# then re-added by apply_publication with the new column name. +PUBLICATION_COLUMN_LIST_TABLES = [ + "documents", + "automations", + "new_chat_threads", + "podcasts", +] + +# (old_table, new_table) +TABLE_RENAMES: list[tuple[str, str]] = [ + ("searchspaces", "workspaces"), + ("search_space_roles", "workspace_roles"), + ("search_space_memberships", "workspace_memberships"), + ("search_space_invites", "workspace_invites"), +] + +# (table, old_column, new_column) -- table names are POST-table-rename. +COLUMN_RENAMES: list[tuple[str, str, str]] = [ + ("agent_action_log", "search_space_id", "workspace_id"), + ("agent_permission_rules", "search_space_id", "workspace_id"), + ("automations", "search_space_id", "workspace_id"), + ("connections", "search_space_id", "workspace_id"), + ("document_files", "search_space_id", "workspace_id"), + ("document_revisions", "search_space_id", "workspace_id"), + ("documents", "search_space_id", "workspace_id"), + ("external_chat_bindings", "search_space_id", "workspace_id"), + ("folder_revisions", "search_space_id", "workspace_id"), + ("folders", "search_space_id", "workspace_id"), + ("image_generations", "search_space_id", "workspace_id"), + ("logs", "search_space_id", "workspace_id"), + ("new_chat_threads", "search_space_id", "workspace_id"), + ("notifications", "search_space_id", "workspace_id"), + ("podcasts", "search_space_id", "workspace_id"), + ("prompts", "search_space_id", "workspace_id"), + ("reports", "search_space_id", "workspace_id"), + ("search_source_connectors", "search_space_id", "workspace_id"), + ("token_usage", "search_space_id", "workspace_id"), + ("video_presentations", "search_space_id", "workspace_id"), + ("external_chat_accounts", "owner_search_space_id", "owner_workspace_id"), + ("workspace_roles", "search_space_id", "workspace_id"), + ("workspace_memberships", "search_space_id", "workspace_id"), + ("workspace_invites", "search_space_id", "workspace_id"), +] + +# (table, old_constraint, new_constraint) -- table names are POST-table-rename. +CONSTRAINT_RENAMES: list[tuple[str, str, str]] = [ + ( + "agent_action_log", + "agent_action_log_search_space_id_fkey", + "agent_action_log_workspace_id_fkey", + ), + ( + "agent_permission_rules", + "agent_permission_rules_search_space_id_fkey", + "agent_permission_rules_workspace_id_fkey", + ), + ( + "automations", + "automations_search_space_id_fkey", + "automations_workspace_id_fkey", + ), + ( + "connections", + "connections_search_space_id_fkey", + "connections_workspace_id_fkey", + ), + ( + "document_files", + "document_files_search_space_id_fkey", + "document_files_workspace_id_fkey", + ), + ( + "document_revisions", + "document_revisions_search_space_id_fkey", + "document_revisions_workspace_id_fkey", + ), + ("documents", "documents_search_space_id_fkey", "documents_workspace_id_fkey"), + ( + "external_chat_accounts", + "external_chat_accounts_owner_search_space_id_fkey", + "external_chat_accounts_owner_workspace_id_fkey", + ), + ( + "external_chat_bindings", + "external_chat_bindings_search_space_id_fkey", + "external_chat_bindings_workspace_id_fkey", + ), + ( + "folder_revisions", + "folder_revisions_search_space_id_fkey", + "folder_revisions_workspace_id_fkey", + ), + ("folders", "folders_search_space_id_fkey", "folders_workspace_id_fkey"), + ( + "image_generations", + "image_generations_search_space_id_fkey", + "image_generations_workspace_id_fkey", + ), + ("logs", "logs_search_space_id_fkey", "logs_workspace_id_fkey"), + ( + "new_chat_threads", + "new_chat_threads_search_space_id_fkey", + "new_chat_threads_workspace_id_fkey", + ), + ( + "notifications", + "notifications_search_space_id_fkey", + "notifications_workspace_id_fkey", + ), + ("podcasts", "podcasts_search_space_id_fkey", "podcasts_workspace_id_fkey"), + ("prompts", "prompts_search_space_id_fkey", "prompts_workspace_id_fkey"), + ("reports", "reports_search_space_id_fkey", "reports_workspace_id_fkey"), + ( + "search_source_connectors", + "search_source_connectors_search_space_id_fkey", + "search_source_connectors_workspace_id_fkey", + ), + ( + "search_source_connectors", + "uq_searchspace_user_connector_type_name", + "uq_workspace_user_connector_type_name", + ), + ( + "token_usage", + "token_usage_search_space_id_fkey", + "token_usage_workspace_id_fkey", + ), + ( + "video_presentations", + "video_presentations_search_space_id_fkey", + "video_presentations_workspace_id_fkey", + ), + ( + "workspace_invites", + "search_space_invites_created_by_id_fkey", + "workspace_invites_created_by_id_fkey", + ), + ( + "workspace_invites", + "search_space_invites_pkey", + "workspace_invites_pkey", + ), + ( + "workspace_invites", + "search_space_invites_role_id_fkey", + "workspace_invites_role_id_fkey", + ), + ( + "workspace_invites", + "search_space_invites_search_space_id_fkey", + "workspace_invites_workspace_id_fkey", + ), + ( + "workspace_memberships", + "search_space_memberships_invited_by_invite_id_fkey", + "workspace_memberships_invited_by_invite_id_fkey", + ), + ( + "workspace_memberships", + "search_space_memberships_pkey", + "workspace_memberships_pkey", + ), + ( + "workspace_memberships", + "search_space_memberships_role_id_fkey", + "workspace_memberships_role_id_fkey", + ), + ( + "workspace_memberships", + "search_space_memberships_search_space_id_fkey", + "workspace_memberships_workspace_id_fkey", + ), + ( + "workspace_memberships", + "search_space_memberships_user_id_fkey", + "workspace_memberships_user_id_fkey", + ), + ( + "workspace_memberships", + "uq_user_searchspace_membership", + "uq_user_workspace_membership", + ), + ( + "workspace_roles", + "search_space_roles_pkey", + "workspace_roles_pkey", + ), + ( + "workspace_roles", + "search_space_roles_search_space_id_fkey", + "workspace_roles_workspace_id_fkey", + ), + ( + "workspace_roles", + "uq_searchspace_role_name", + "uq_workspace_role_name", + ), + ("workspaces", "searchspaces_pkey", "workspaces_pkey"), + ("workspaces", "searchspaces_user_id_fkey", "workspaces_user_id_fkey"), +] + +# (old_index, new_index) -- plain indexes only; PK/unique-backed indexes follow +# their RENAME CONSTRAINT above. ALTER INDEX IF EXISTS covers both create_all +# indexes and the runtime ``setup_indexes()`` ones (idx_documents_*). +INDEX_RENAMES: list[tuple[str, str]] = [ + ("ix_agent_action_log_search_space_id", "ix_agent_action_log_workspace_id"), + ( + "ix_agent_permission_rules_search_space_id", + "ix_agent_permission_rules_workspace_id", + ), + ("ix_automations_search_space_id", "ix_automations_workspace_id"), + ("ix_document_files_search_space_id", "ix_document_files_workspace_id"), + ("ix_document_revisions_search_space_id", "ix_document_revisions_workspace_id"), + ( + "ix_external_chat_bindings_search_space_state", + "ix_external_chat_bindings_workspace_state", + ), + ("ix_folder_revisions_search_space_id", "ix_folder_revisions_workspace_id"), + ("ix_folders_search_space_id", "ix_folders_workspace_id"), + ("ix_notifications_search_space_id", "ix_notifications_workspace_id"), + ("ix_notifications_user_space_created", "ix_notifications_user_workspace_created"), + ("ix_prompts_search_space_id", "ix_prompts_workspace_id"), + ("ix_token_usage_search_space_id", "ix_token_usage_workspace_id"), + ("ix_search_space_invites_created_at", "ix_workspace_invites_created_at"), + ("ix_search_space_invites_id", "ix_workspace_invites_id"), + ("ix_search_space_invites_invite_code", "ix_workspace_invites_invite_code"), + ("ix_search_space_memberships_created_at", "ix_workspace_memberships_created_at"), + ("ix_search_space_memberships_id", "ix_workspace_memberships_id"), + ("ix_search_space_roles_created_at", "ix_workspace_roles_created_at"), + ("ix_search_space_roles_id", "ix_workspace_roles_id"), + ("ix_search_space_roles_name", "ix_workspace_roles_name"), + ("ix_searchspaces_created_at", "ix_workspaces_created_at"), + ("ix_searchspaces_id", "ix_workspaces_id"), + ("ix_searchspaces_name", "ix_workspaces_name"), + ("idx_documents_search_space_id", "idx_documents_workspace_id"), + ("idx_documents_search_space_updated", "idx_documents_workspace_updated"), +] + +# (old_sequence, new_sequence) +SEQUENCE_RENAMES: list[tuple[str, str]] = [ + ("searchspaces_id_seq", "workspaces_id_seq"), + ("search_space_roles_id_seq", "workspace_roles_id_seq"), + ("search_space_memberships_id_seq", "workspace_memberships_id_seq"), + ("search_space_invites_id_seq", "workspace_invites_id_seq"), +] + +# ---- Hardcoded OLD-shape publication (for downgrade only; finding 7). ---- +# Mirrors app.zero_publication.ZERO_PUBLICATION at revision 169 but with the +# pre-rename ``search_space_id`` column. NEVER import the live module here: it +# now reflects ``workspace_id`` and would silently drop the column-list tables. +_DOWNGRADE_DOCUMENT_COLS = [ + "id", + "title", + "document_type", + "search_space_id", + "folder_id", + "created_by_id", + "status", + "created_at", + "updated_at", +] +_DOWNGRADE_USER_COLS = ["id", "credit_micros_balance"] +_DOWNGRADE_AUTOMATION_RUN_COLS = [ + "id", + "automation_id", + "trigger_id", + "status", + "step_results", + "started_at", + "finished_at", + "created_at", +] +_DOWNGRADE_AUTOMATION_COLS = ["id", "search_space_id"] +_DOWNGRADE_NEW_CHAT_THREAD_COLS = ["id", "search_space_id"] +_DOWNGRADE_PODCAST_COLS = [ + "id", + "title", + "status", + "spec", + "spec_version", + "duration_seconds", + "error", + "search_space_id", + "thread_id", + "created_at", +] +# Ordered to match ZERO_PUBLICATION; None == all columns. +_DOWNGRADE_PUBLICATION: list[tuple[str, list[str] | None]] = [ + ("notifications", None), + ("documents", _DOWNGRADE_DOCUMENT_COLS), + ("folders", None), + ("search_source_connectors", None), + ("new_chat_threads", _DOWNGRADE_NEW_CHAT_THREAD_COLS), + ("new_chat_messages", None), + ("chat_comments", None), + ("chat_session_state", None), + ("user", _DOWNGRADE_USER_COLS), + ("automations", _DOWNGRADE_AUTOMATION_COLS), + ("automation_runs", _DOWNGRADE_AUTOMATION_RUN_COLS), + ("podcasts", _DOWNGRADE_PODCAST_COLS), +] + + +def _quote(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def _publication_exists(conn) -> bool: + return ( + conn.execute( + sa.text("SELECT 1 FROM pg_publication WHERE pubname = :n"), + {"n": PUBLICATION_NAME}, + ).fetchone() + is not None + ) + + +def _is_publication_member(conn, table: str) -> bool: + return ( + conn.execute( + sa.text( + "SELECT 1 FROM pg_publication_tables " + "WHERE pubname = :n AND schemaname = current_schema() " + "AND tablename = :t" + ), + {"n": PUBLICATION_NAME, "t": table}, + ).fetchone() + is not None + ) + + +def _table_columns(conn, table: str) -> set[str]: + rows = conn.execute( + sa.text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema = current_schema() AND table_name = :t" + ), + {"t": table}, + ).fetchall() + return {row[0] for row in rows} + + +def _neutralize_column_list_tables(conn) -> None: + """Remove the column-list dependency so RENAME COLUMN is permitted.""" + if not _publication_exists(conn): + return + for table in PUBLICATION_COLUMN_LIST_TABLES: + if _is_publication_member(conn, table): + conn.execute( + sa.text(f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} DROP TABLE {table}") + ) + + +def _rename_table(conn, old: str, new: str) -> None: + conn.execute(sa.text(f"ALTER TABLE IF EXISTS {old} RENAME TO {new}")) + + +def _rename_column(conn, table: str, old: str, new: str) -> None: + conn.execute( + sa.text( + f""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = '{table}' AND column_name = '{old}' + ) THEN + ALTER TABLE {table} RENAME COLUMN {old} TO {new}; + END IF; + END$$; + """ + ) + ) + + +def _rename_constraint(conn, table: str, old: str, new: str) -> None: + conn.execute( + sa.text( + f""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = '{old}' + AND conrelid = to_regclass('{table}') + ) THEN + ALTER TABLE {table} RENAME CONSTRAINT {old} TO {new}; + END IF; + END$$; + """ + ) + ) + + +def _rename_index(conn, old: str, new: str) -> None: + conn.execute(sa.text(f"ALTER INDEX IF EXISTS {old} RENAME TO {new}")) + + +def _rename_sequence(conn, old: str, new: str) -> None: + conn.execute(sa.text(f"ALTER SEQUENCE IF EXISTS {old} RENAME TO {new}")) + + +def _restore_downgrade_publication(conn) -> None: + """Re-emit the OLD (search_space_id) publication shape via plain SET TABLE. + + Does NOT call apply_publication (which reads the live, now-workspace_id + module) -- that would silently drop the column-list tables (finding 7). + """ + if not _publication_exists(conn): + return + # Drop the column-list tables first so the SET TABLE has no stale + # workspace_id dependency to trip over. + for table in PUBLICATION_COLUMN_LIST_TABLES: + if _is_publication_member(conn, table): + conn.execute( + sa.text(f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} DROP TABLE {table}") + ) + + entries: list[str] = [] + for table, cols in _DOWNGRADE_PUBLICATION: + actual = _table_columns(conn, table) + if not actual: + continue + if cols is None: + entries.append(_quote(table)) + continue + expected = list(cols) + if table in {"documents", "user", "podcasts"} and "_0_version" in actual: + expected.append("_0_version") + if any(col not in actual for col in expected): + continue + col_sql = ", ".join(_quote(col) for col in expected) + entries.append(f"{_quote(table)} ({col_sql})") + + table_list = ", ".join(entries) + conn.execute( + sa.text(f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} SET TABLE {table_list}") + ) + + +def upgrade() -> None: + conn = op.get_bind() + + _neutralize_column_list_tables(conn) + + for old, new in TABLE_RENAMES: + _rename_table(conn, old, new) + for table, old, new in COLUMN_RENAMES: + _rename_column(conn, table, old, new) + for table, old, new in CONSTRAINT_RENAMES: + _rename_constraint(conn, table, old, new) + for old, new in INDEX_RENAMES: + _rename_index(conn, old, new) + for old, new in SEQUENCE_RENAMES: + _rename_sequence(conn, old, new) + + # Reconcile to the new workspace_id shape via the blessed apply_publication + # path (ALTER ... SET TABLE) -- never raw DROP/CREATE PUBLICATION (bug #1355, + # migration 116). No-op if the publication does not exist. + apply_publication(conn) + + +def downgrade() -> None: + conn = op.get_bind() + + _neutralize_column_list_tables(conn) + + for old, new in SEQUENCE_RENAMES: + _rename_sequence(conn, new, old) + for old, new in INDEX_RENAMES: + _rename_index(conn, new, old) + for table, old, new in CONSTRAINT_RENAMES: + # table here is the POST-rename name; constraints/tables are still + # renamed at this point (table rename is reversed last). + _rename_constraint(conn, table, new, old) + for table, old, new in COLUMN_RENAMES: + _rename_column(conn, table, new, old) + for old, new in TABLE_RENAMES: + _rename_table(conn, new, old) + + _restore_downgrade_publication(conn) diff --git a/surfsense_backend/app/agents/chat/anonymous_chat/agent.py b/surfsense_backend/app/agents/chat/anonymous_chat/agent.py index 250b4c158..eb0f46037 100644 --- a/surfsense_backend/app/agents/chat/anonymous_chat/agent.py +++ b/surfsense_backend/app/agents/chat/anonymous_chat/agent.py @@ -124,7 +124,7 @@ async def create_anonymous_chat_agent( tools (used when the user toggles web search off). """ tools = ( - [create_web_search_tool(search_space_id=None, available_connectors=None)] + [create_web_search_tool(workspace_id=None, available_connectors=None)] if enable_web_search else [] ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/graph/compile_graph_sync.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/graph/compile_graph_sync.py index e3ab50e8c..2bf393d1f 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/graph/compile_graph_sync.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/graph/compile_graph_sync.py @@ -31,7 +31,7 @@ def build_compiled_agent_graph_sync( final_system_prompt: str, backend_resolver: Any, filesystem_mode: FilesystemMode, - search_space_id: int, + workspace_id: int, user_id: str | None, thread_id: int | None, visibility: ChatVisibility, @@ -53,7 +53,7 @@ def build_compiled_agent_graph_sync( tools=tools, backend_resolver=backend_resolver, filesystem_mode=filesystem_mode, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, thread_id=thread_id, visibility=visibility, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/action_log/builder.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/action_log/builder.py index 9213f1339..6a2c737d8 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/action_log/builder.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/action_log/builder.py @@ -14,7 +14,7 @@ def build_action_log_mw( *, flags: AgentFeatureFlags, thread_id: int | None, - search_space_id: int, + workspace_id: int, user_id: str | None, ) -> ActionLogMiddleware | None: if not enabled(flags, "enable_action_log") or thread_id is None: @@ -25,7 +25,7 @@ def build_action_log_mw( # tool via ``ToolDefinition.reverse`` and can be wired here when used. return ActionLogMiddleware( thread_id=thread_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) except Exception: # pragma: no cover - defensive diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/action_log/middleware.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/action_log/middleware.py index 2cce7eb53..918d6c784 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/action_log/middleware.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/action_log/middleware.py @@ -84,7 +84,7 @@ class ActionLogMiddleware(AgentMiddleware): Args: thread_id: The current chat thread's primary-key id. Required to persist a row; if ``None`` the middleware silently no-ops. - search_space_id: Search-space id for cascade-on-delete safety. + workspace_id: Workspace id for cascade-on-delete safety. user_id: UUID string of the user driving this turn (nullable in anonymous mode). tool_definitions: Optional mapping of tool name -> :class:`ToolDefinition` @@ -98,13 +98,13 @@ class ActionLogMiddleware(AgentMiddleware): self, *, thread_id: int | None, - search_space_id: int, + workspace_id: int, user_id: str | None, tool_definitions: dict[str, ToolDefinition] | None = None, ) -> None: super().__init__() self._thread_id = thread_id - self._search_space_id = search_space_id + self._workspace_id = workspace_id self._user_id = user_id self._tool_definitions = dict(tool_definitions or {}) @@ -187,7 +187,7 @@ class ActionLogMiddleware(AgentMiddleware): row = AgentActionLog( thread_id=thread_id, user_id=self._user_id, - search_space_id=self._search_space_id, + workspace_id=self._workspace_id, # ``turn_id`` is the deprecated alias of ``tool_call_id`` # kept for one release for safe rollback. New consumers # should read ``tool_call_id`` directly. diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/middleware.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/middleware.py index ab6c3a1f5..8c4117bf9 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/middleware.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/middleware.py @@ -40,7 +40,7 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware): subagents: list[SubAgent | CompiledSubAgent], system_prompt: str | None = TASK_SYSTEM_PROMPT, task_description: str | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, ) -> None: self._surf_checkpointer = checkpointer super(SubAgentMiddleware, self).__init__() @@ -50,11 +50,11 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware): ) self._backend = backend self._subagents = subagents - # Search-space id is captured at build time (the orchestrator runs in - # exactly one search space for its lifetime). The spawn-paused kill + # Workspace id is captured at build time (the orchestrator runs in + # exactly one workspace for its lifetime). The spawn-paused kill # switch keys on it so an operator can quarantine one workspace # without affecting the rest of the deployment. - self._search_space_id = search_space_id + self._workspace_id = workspace_id # Lazy subagent compilation. Compiling a subagent graph via # ``create_agent`` is expensive (~250-400ms each) and there can be up @@ -75,7 +75,7 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware): task_tool = build_task_tool_with_parent_config( descriptors, task_description, - search_space_id=search_space_id, + workspace_id=workspace_id, resolve_subagent=self._resolve_subagent, ) if system_prompt and descriptors: diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/spawn_paused.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/spawn_paused.py index 2c9e114e0..50f91dfc4 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/spawn_paused.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/spawn_paused.py @@ -1,12 +1,12 @@ -"""Per-search-space spawn-paused kill switch for the ``task`` boundary. +"""Per-workspace spawn-paused kill switch for the ``task`` boundary. When operators see a runaway loop, a vendor outage, or a billing event that requires immediate cessation of subagent traffic for a specific workspace, they flip a Redis flag and the ``task`` tool short-circuits -without touching downstream services. The flag is **per-search-space** +without touching downstream services. The flag is **per-workspace** so one tenant's incident never silences the rest of the deployment. -Flag key: ``surfsense:spawn_paused:{search_space_id}`` +Flag key: ``surfsense:spawn_paused:{workspace_id}`` Flag value: any string-truthy value (we read presence, not contents). TTL: set by whoever toggles the flag — this module never expires keys on its own, since "the flag is on" is itself the signal @@ -43,18 +43,18 @@ _DISABLED = os.environ.get( } -def _flag_key(search_space_id: int) -> str: - return f"surfsense:spawn_paused:{search_space_id}" +def _flag_key(workspace_id: int) -> str: + return f"surfsense:spawn_paused:{workspace_id}" -async def is_spawn_paused(search_space_id: int | None) -> bool: +async def is_spawn_paused(workspace_id: int | None) -> bool: """Return ``True`` iff the workspace's spawn-paused flag is set in Redis. - A ``None`` search-space (e.g. dev paths that did not plumb the id + A ``None`` workspace (e.g. dev paths that did not plumb the id through yet) bypasses the check. So does a Redis outage — see module docstring for the fail-open rationale. """ - if _DISABLED or search_space_id is None: + if _DISABLED or workspace_id is None: return False try: # Local import keeps the cold-path import cheap and lets routes @@ -63,7 +63,7 @@ async def is_spawn_paused(search_space_id: int | None) -> bool: client = aioredis.from_url(config.REDIS_APP_URL, decode_responses=True) try: - raw = await client.get(_flag_key(search_space_id)) + raw = await client.get(_flag_key(workspace_id)) finally: # ``aclose()`` is the async-safe variant on redis-py >=5; fall back # to ``close()`` for older clients pinned in tests. @@ -74,8 +74,8 @@ async def is_spawn_paused(search_space_id: int | None) -> bool: return bool(raw) except Exception: logger.warning( - "spawn_paused check failed for search_space_id=%s; failing open.", - search_space_id, + "spawn_paused check failed for workspace_id=%s; failing open.", + workspace_id, exc_info=True, ) return False diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/task_tool.py index 6698211f7..a08e6cda1 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/task_tool.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/checkpointed_subagent_middleware/task_tool.py @@ -142,7 +142,7 @@ def build_task_tool_with_parent_config( subagents: list[dict[str, Any]], task_description: str | None = None, *, - search_space_id: int | None = None, + workspace_id: int | None = None, resolve_subagent: Callable[[str], Runnable] | None = None, ) -> BaseTool: """Upstream ``_build_task_tool`` + parent ``runtime.config`` propagation + resume bridging. @@ -829,10 +829,10 @@ def build_task_tool_with_parent_config( atask_start = time.perf_counter() # Ops kill switch: short-circuit every task() call for this workspace # so the orchestrator stops hammering downstream APIs. - if await is_spawn_paused(search_space_id): + if await is_spawn_paused(workspace_id): logger.warning( - "[hitl_route] atask SPAWN_PAUSED: search_space_id=%s tool_call_id=%s", - search_space_id, + "[hitl_route] atask SPAWN_PAUSED: workspace_id=%s tool_call_id=%s", + workspace_id, runtime.tool_call_id, ) return ( diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/builder.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/builder.py index 7e8e06570..eea2a1073 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/builder.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/builder.py @@ -12,14 +12,14 @@ from .middleware import ( def build_kb_persistence_mw( *, filesystem_mode: FilesystemMode, - search_space_id: int, + workspace_id: int, user_id: str | None, thread_id: int | None, ) -> KnowledgeBasePersistenceMiddleware | None: if filesystem_mode != FilesystemMode.CLOUD: return None return KnowledgeBasePersistenceMiddleware( - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=user_id, filesystem_mode=filesystem_mode, thread_id=thread_id, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/middleware.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/middleware.py index a6c83a7d4..0554dc747 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/middleware.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/kb_persistence/middleware.py @@ -83,11 +83,11 @@ def _basename(path: str) -> str: async def _ensure_folder_hierarchy( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, created_by_id: str | None, folder_parts: list[str], ) -> int | None: - """Ensure a chain of folder names exists under the search space. + """Ensure a chain of folder names exists under the workspace. Returns the leaf folder id, or ``None`` if ``folder_parts`` is empty (i.e. a document directly under ``/documents/``). @@ -98,7 +98,7 @@ async def _ensure_folder_hierarchy( for raw in folder_parts: name = safe_folder_segment(str(raw)) query = select(Folder).where( - Folder.search_space_id == search_space_id, + Folder.workspace_id == workspace_id, Folder.name == name, ) if parent_id is None: @@ -112,7 +112,7 @@ async def _ensure_folder_hierarchy( select(Folder.position).order_by(Folder.position.desc()).limit(1) ) sibling_query = sibling_query.where( - Folder.search_space_id == search_space_id + Folder.workspace_id == workspace_id ) if parent_id is None: sibling_query = sibling_query.where(Folder.parent_id.is_(None)) @@ -124,7 +124,7 @@ async def _ensure_folder_hierarchy( name=name, position=generate_key_between(last_position, None), parent_id=parent_id, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=created_by_id, updated_at=datetime.now(UTC), ) @@ -137,7 +137,7 @@ async def _ensure_folder_hierarchy( async def _resolve_folder_id( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, folder_parts: list[str], ) -> int | None: """Look up an existing folder chain without creating anything. @@ -151,7 +151,7 @@ async def _resolve_folder_id( for raw in folder_parts: name = safe_folder_segment(str(raw)) query = select(Folder).where( - Folder.search_space_id == search_space_id, + Folder.workspace_id == workspace_id, Folder.name == name, ) query = ( @@ -185,7 +185,7 @@ async def _create_document( *, virtual_path: str, content: str, - search_space_id: int, + workspace_id: int, created_by_id: str | None, ) -> Document: """Create a NOTE Document + Chunks for ``virtual_path``.""" @@ -194,21 +194,21 @@ async def _create_document( raise ValueError(f"invalid /documents path '{virtual_path}'") folder_id = await _ensure_folder_hierarchy( session, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=created_by_id, folder_parts=folder_parts, ) unique_identifier_hash = generate_unique_identifier_hash( DocumentType.NOTE, virtual_path, - search_space_id, + workspace_id, ) # Pre-check the path-derived unique_identifier_hash so a duplicate path # surfaces as a clean ValueError instead of an INSERT IntegrityError that # poisons the session. Content is intentionally not unique (cp a b). path_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, ) ) @@ -217,7 +217,7 @@ async def _create_document( f"a document already exists at path '{virtual_path}' " "(unique_identifier_hash collision)" ) - content_hash = generate_content_hash(content, search_space_id) + content_hash = generate_content_hash(content, workspace_id) doc = Document( title=title, document_type=DocumentType.NOTE, @@ -226,7 +226,7 @@ async def _create_document( content_hash=content_hash, unique_identifier_hash=unique_identifier_hash, source_markdown=content, - search_space_id=search_space_id, + workspace_id=workspace_id, folder_id=folder_id, created_by_id=created_by_id, updated_at=datetime.now(UTC), @@ -261,13 +261,13 @@ async def _update_document( doc_id: int, content: str, virtual_path: str, - search_space_id: int, + workspace_id: int, ) -> Document | None: """Update an existing Document's content + chunks.""" result = await session.execute( select(Document).where( Document.id == doc_id, - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, ) ) document = result.scalar_one_or_none() @@ -276,7 +276,7 @@ async def _update_document( document.content = content document.source_markdown = content - document.content_hash = generate_content_hash(content, search_space_id) + document.content_hash = generate_content_hash(content, workspace_id) document.updated_at = datetime.now(UTC) metadata = dict(document.document_metadata or {}) metadata["virtual_path"] = virtual_path @@ -284,7 +284,7 @@ async def _update_document( document.unique_identifier_hash = generate_unique_identifier_hash( DocumentType.NOTE, virtual_path, - search_space_id, + workspace_id, ) summary_embedding = (await asyncio.to_thread(embed_texts, [content]))[0] @@ -318,7 +318,7 @@ async def _update_document( async def _apply_move( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, created_by_id: str | None, move: dict[str, Any], doc_id_by_path: dict[str, int], @@ -341,14 +341,14 @@ async def _apply_move( result = await session.execute( select(Document).where( Document.id == doc_id, - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, ) ) document = result.scalar_one_or_none() if document is None: document = await virtual_path_to_doc( session, - search_space_id=search_space_id, + workspace_id=workspace_id, virtual_path=source, ) if document is None: @@ -364,7 +364,7 @@ async def _apply_move( return None folder_id = await _ensure_folder_hierarchy( session, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=created_by_id, folder_parts=folder_parts, ) @@ -377,7 +377,7 @@ async def _apply_move( document.unique_identifier_hash = generate_unique_identifier_hash( DocumentType.NOTE, dest, - search_space_id, + workspace_id, ) document.updated_at = datetime.now(UTC) @@ -501,7 +501,7 @@ async def _snapshot_document_pre_write( *, doc: Document, action_id: int | None, - search_space_id: int, + workspace_id: int, turn_id: str | None = None, deferred_dispatches: list[int] | None = None, ) -> int | None: @@ -517,7 +517,7 @@ async def _snapshot_document_pre_write( payload = _doc_revision_payload(doc, chunks_before=chunks) rev = DocumentRevision( document_id=doc.id, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_turn_id=turn_id, agent_action_id=action_id, **payload, @@ -544,7 +544,7 @@ async def _snapshot_document_pre_create( session: AsyncSession, *, action_id: int | None, - search_space_id: int, + workspace_id: int, turn_id: str | None = None, deferred_dispatches: list[int] | None = None, ) -> int | None: @@ -558,7 +558,7 @@ async def _snapshot_document_pre_create( async with session.begin_nested(): rev = DocumentRevision( document_id=None, - search_space_id=search_space_id, + workspace_id=workspace_id, content_before=None, title_before=None, folder_id_before=None, @@ -586,7 +586,7 @@ async def _snapshot_document_pre_move( *, doc: Document, action_id: int | None, - search_space_id: int, + workspace_id: int, turn_id: str | None = None, deferred_dispatches: list[int] | None = None, ) -> int | None: @@ -596,7 +596,7 @@ async def _snapshot_document_pre_move( payload = _doc_revision_payload(doc, chunks_before=None) rev = DocumentRevision( document_id=doc.id, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_turn_id=turn_id, agent_action_id=action_id, **payload, @@ -624,7 +624,7 @@ async def _snapshot_folder_pre_mkdir( *, folder: Folder, action_id: int | None, - search_space_id: int, + workspace_id: int, turn_id: str | None = None, deferred_dispatches: list[int] | None = None, ) -> int | None: @@ -637,7 +637,7 @@ async def _snapshot_folder_pre_mkdir( async with session.begin_nested(): rev = FolderRevision( folder_id=folder.id, - search_space_id=search_space_id, + workspace_id=workspace_id, name_before=None, parent_id_before=None, position_before=None, @@ -670,7 +670,7 @@ async def _snapshot_folder_pre_mkdir( async def commit_staged_filesystem_state( state: dict[str, Any] | AgentState, *, - search_space_id: int, + workspace_id: int, created_by_id: str | None, filesystem_mode: FilesystemMode = FilesystemMode.CLOUD, thread_id: int | None = None, @@ -814,7 +814,7 @@ async def commit_staged_filesystem_state( continue folder_id = await _ensure_folder_hierarchy( session, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=created_by_id, folder_parts=folder_parts_full, ) @@ -833,7 +833,7 @@ async def commit_staged_filesystem_state( session, folder=folder_row, action_id=action_id, - search_space_id=search_space_id, + workspace_id=workspace_id, turn_id=tcid, deferred_dispatches=deferred_dispatches, ) @@ -851,14 +851,14 @@ async def commit_staged_filesystem_state( res_pre = await session.execute( select(Document).where( Document.id == doc_id_pre, - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, ) ) document_pre = res_pre.scalar_one_or_none() if document_pre is None: document_pre = await virtual_path_to_doc( session, - search_space_id=search_space_id, + workspace_id=workspace_id, virtual_path=source, ) if document_pre is not None: @@ -866,14 +866,14 @@ async def commit_staged_filesystem_state( session, doc=document_pre, action_id=action_id, - search_space_id=search_space_id, + workspace_id=workspace_id, turn_id=tcid, deferred_dispatches=deferred_dispatches, ) applied = await _apply_move( session, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=created_by_id, move=move, doc_id_by_path=doc_id_by_path, @@ -937,7 +937,7 @@ async def commit_staged_filesystem_state( # INSERT (which would hit the path-derived unique hash). existing = await virtual_path_to_doc( session, - search_space_id=search_space_id, + workspace_id=workspace_id, virtual_path=path, ) if existing is not None: @@ -948,7 +948,7 @@ async def commit_staged_filesystem_state( result_doc = await session.execute( select(Document).where( Document.id == doc_id, - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, ) ) existing_doc = result_doc.scalar_one_or_none() @@ -957,7 +957,7 @@ async def commit_staged_filesystem_state( session, doc=existing_doc, action_id=action_id, - search_space_id=search_space_id, + workspace_id=workspace_id, turn_id=tcid, deferred_dispatches=deferred_dispatches, ) @@ -966,7 +966,7 @@ async def commit_staged_filesystem_state( doc_id=doc_id, content=content, virtual_path=path, - search_space_id=search_space_id, + workspace_id=workspace_id, ) if updated is not None: committed_updates.append( @@ -974,7 +974,7 @@ async def commit_staged_filesystem_state( "id": updated.id, "title": updated.title, "documentType": DocumentType.NOTE.value, - "searchSpaceId": search_space_id, + "workspaceId": workspace_id, "folderId": updated.folder_id, "createdById": str(created_by_id) if created_by_id @@ -991,7 +991,7 @@ async def commit_staged_filesystem_state( placeholder_revision_id = await _snapshot_document_pre_create( session, action_id=action_id, - search_space_id=search_space_id, + workspace_id=workspace_id, turn_id=tcid, deferred_dispatches=deferred_dispatches, ) @@ -1001,7 +1001,7 @@ async def commit_staged_filesystem_state( session, virtual_path=path, content=content, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=created_by_id, ) except ValueError as exc: @@ -1045,7 +1045,7 @@ async def commit_staged_filesystem_state( "id": new_doc.id, "title": new_doc.title, "documentType": DocumentType.NOTE.value, - "searchSpaceId": search_space_id, + "workspaceId": workspace_id, "folderId": new_doc.folder_id, "createdById": str(created_by_id) if created_by_id @@ -1069,14 +1069,14 @@ async def commit_staged_filesystem_state( result = await session.execute( select(Document).where( Document.id == doc_id_for_delete, - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, ) ) document_to_delete = result.scalar_one_or_none() if document_to_delete is None: document_to_delete = await virtual_path_to_doc( session, - search_space_id=search_space_id, + workspace_id=workspace_id, virtual_path=final, ) if document_to_delete is None: @@ -1100,7 +1100,7 @@ async def commit_staged_filesystem_state( ) rev = DocumentRevision( document_id=doc_pk, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_turn_id=tcid, agent_action_id=action_id, **payload, @@ -1130,7 +1130,7 @@ async def commit_staged_filesystem_state( "id": doc_pk, "title": doc_title, "documentType": DocumentType.NOTE.value, - "searchSpaceId": search_space_id, + "workspaceId": workspace_id, "folderId": doc_folder_id, "createdById": str(created_by_id) if created_by_id else None, "virtualPath": final, @@ -1151,7 +1151,7 @@ async def commit_staged_filesystem_state( continue folder_id = await _resolve_folder_id( session, - search_space_id=search_space_id, + workspace_id=workspace_id, folder_parts=folder_parts, ) if folder_id is None: @@ -1163,7 +1163,7 @@ async def commit_staged_filesystem_state( docs_in_folder = await session.execute( select(Document.id) .where(Document.folder_id == folder_id) - .where(Document.search_space_id == search_space_id) + .where(Document.workspace_id == workspace_id) .limit(1) ) if docs_in_folder.scalar_one_or_none() is not None: @@ -1175,7 +1175,7 @@ async def commit_staged_filesystem_state( child_folders = await session.execute( select(Folder.id) .where(Folder.parent_id == folder_id) - .where(Folder.search_space_id == search_space_id) + .where(Folder.workspace_id == workspace_id) .limit(1) ) if child_folders.scalar_one_or_none() is not None: @@ -1203,7 +1203,7 @@ async def commit_staged_filesystem_state( if snapshot_enabled and action_id is not None: rev = FolderRevision( folder_id=folder_pk, - search_space_id=search_space_id, + workspace_id=workspace_id, name_before=folder_name, parent_id_before=folder_parent_id, position_before=folder_position, @@ -1232,7 +1232,7 @@ async def commit_staged_filesystem_state( { "id": folder_pk, "name": folder_name, - "searchSpaceId": search_space_id, + "workspaceId": workspace_id, "parentId": folder_parent_id, "virtualPath": final, } @@ -1242,7 +1242,7 @@ async def commit_staged_filesystem_state( await session.commit() except Exception: # pragma: no cover - rollback safety net logger.exception( - "kb_persistence: commit failed (search_space=%s)", search_space_id + "kb_persistence: commit failed (workspace=%s)", workspace_id ) # Outer commit raised: everything above rolled back, so drop the # deferred dispatches. @@ -1402,9 +1402,9 @@ async def commit_staged_filesystem_state( _ = turn_id_for_revision # diagnostic-only; silence unused lint logger.info( - "kb_persistence: commit (search_space=%s) creates=%d updates=%d " + "kb_persistence: commit (workspace=%s) creates=%d updates=%d " "moves=%d staged_dirs=%d deletes=%d folder_deletes=%d discarded=%d", - search_space_id, + workspace_id, len(committed_creates), len(committed_updates), len(applied_moves), @@ -1430,12 +1430,12 @@ class KnowledgeBasePersistenceMiddleware(AgentMiddleware): # type: ignore[type- def __init__( self, *, - search_space_id: int, + workspace_id: int, created_by_id: str | None, filesystem_mode: FilesystemMode, thread_id: int | None = None, ) -> None: - self.search_space_id = search_space_id + self.workspace_id = workspace_id self.created_by_id = created_by_id self.filesystem_mode = filesystem_mode self.thread_id = thread_id @@ -1450,7 +1450,7 @@ class KnowledgeBasePersistenceMiddleware(AgentMiddleware): # type: ignore[type- return None return await commit_staged_filesystem_state( state, - search_space_id=self.search_space_id, + workspace_id=self.workspace_id, created_by_id=self.created_by_id, filesystem_mode=self.filesystem_mode, thread_id=self._resolve_thread_id(), diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/knowledge_tree/builder.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/knowledge_tree/builder.py index 644d1e55a..af5188237 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/knowledge_tree/builder.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/knowledge_tree/builder.py @@ -12,13 +12,13 @@ from .middleware import KnowledgeTreeMiddleware def build_knowledge_tree_mw( *, filesystem_mode: FilesystemMode, - search_space_id: int, + workspace_id: int, llm: BaseChatModel, ) -> KnowledgeTreeMiddleware | None: if filesystem_mode != FilesystemMode.CLOUD: return None return KnowledgeTreeMiddleware( - search_space_id=search_space_id, + workspace_id=workspace_id, filesystem_mode=filesystem_mode, llm=llm, inject_system_message=False, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/knowledge_tree/middleware.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/knowledge_tree/middleware.py index a0c62834a..27c271c15 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/knowledge_tree/middleware.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/knowledge_tree/middleware.py @@ -1,7 +1,7 @@ """Workspace-tree middleware for the SurfSense agent. Renders the full ``Folder``+``Document`` tree under ``/documents/`` once per -turn (cloud only), caches it by ``(search_space_id, tree_version)``, and +turn (cloud only), caches it by ``(workspace_id, tree_version)``, and injects the result as a ```` system message immediately before the latest human turn. @@ -106,14 +106,14 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg] def __init__( self, *, - search_space_id: int, + workspace_id: int, filesystem_mode: FilesystemMode, llm: BaseChatModel | None = None, max_entries: int = MAX_TREE_ENTRIES, max_tokens: int = MAX_TREE_TOKENS, inject_system_message: bool = True, # For backwards compatibility ) -> None: - self.search_space_id = search_space_id + self.workspace_id = workspace_id self.filesystem_mode = filesystem_mode self.llm = llm self.max_entries = max_entries @@ -141,7 +141,7 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg] cache_outcome = "anon" else: version = int(state.get("tree_version") or 0) - cache_key = (self.search_space_id, version, False) + cache_key = (self.workspace_id, version, False) cache_outcome = "hit" if cache_key in self._cache else "miss" tree_msg = await self._render_kb_tree(state) @@ -158,7 +158,7 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg] cache_outcome, len(tree_msg), time.perf_counter() - start, - self.search_space_id, + self.workspace_id, ) return update @@ -190,17 +190,17 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg] async def _render_kb_tree(self, state: AgentState) -> str: version = int(state.get("tree_version") or 0) - cache_key = (self.search_space_id, version, False) + cache_key = (self.workspace_id, version, False) cached = self._cache.get(cache_key) if cached is not None: return cached try: async with shielded_async_session() as session: - index = await build_path_index(session, self.search_space_id) + index = await build_path_index(session, self.workspace_id) doc_rows = await session.execute( select(Document.id, Document.title, Document.folder_id).where( - Document.search_space_id == self.search_space_id + Document.workspace_id == self.workspace_id ) ) docs = list(doc_rows.all()) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/memory/builder.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/memory/builder.py index 4ea171e13..1b0535b85 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/memory/builder.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/memory/builder.py @@ -10,11 +10,11 @@ from .middleware import MemoryInjectionMiddleware def build_memory_mw( *, user_id: str | None, - search_space_id: int, + workspace_id: int, visibility: ChatVisibility, ) -> MemoryInjectionMiddleware: return MemoryInjectionMiddleware( user_id=user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, thread_visibility=visibility, ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/memory/middleware.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/memory/middleware.py index 9d0fd26a1..98ead2dd7 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/memory/middleware.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/memory/middleware.py @@ -18,7 +18,7 @@ from langgraph.runtime import Runtime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.db import ChatVisibility, SearchSpace, User, shielded_async_session +from app.db import ChatVisibility, Workspace, User, shielded_async_session from app.services.memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT from app.utils.perf import get_perf_logger @@ -35,11 +35,11 @@ class MemoryInjectionMiddleware(AgentMiddleware): # type: ignore[type-arg] self, *, user_id: str | UUID | None, - search_space_id: int, + workspace_id: int, thread_visibility: ChatVisibility | None = None, ) -> None: self.user_id = UUID(user_id) if isinstance(user_id, str) else user_id - self.search_space_id = search_space_id + self.workspace_id = workspace_id self.visibility = thread_visibility or ChatVisibility.PRIVATE async def abefore_agent( # type: ignore[override] @@ -149,8 +149,8 @@ class MemoryInjectionMiddleware(AgentMiddleware): # type: ignore[type-arg] async def _load_team_memory(self, session: AsyncSession) -> str | None: try: result = await session.execute( - select(SearchSpace.shared_memory_md).where( - SearchSpace.id == self.search_space_id + select(Workspace.shared_memory_md).where( + Workspace.id == self.workspace_id ) ) row = result.scalar_one_or_none() diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/plugins.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/plugins.py index 43f4136ec..da8f92a53 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/plugins.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/plugins.py @@ -21,7 +21,7 @@ from ..plugins.loader import ( def build_plugin_middlewares( *, flags: AgentFeatureFlags, - search_space_id: int, + workspace_id: int, user_id: str | None, visibility: ChatVisibility, llm: BaseChatModel, @@ -34,7 +34,7 @@ def build_plugin_middlewares( return [] return load_plugin_middlewares( PluginContext.build( - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, thread_visibility=visibility, llm=llm, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/skills.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/skills.py index 13c62e817..38fb50a24 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/skills.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/skills.py @@ -17,13 +17,13 @@ def build_skills_mw( *, flags: AgentFeatureFlags, filesystem_mode: FilesystemMode, - search_space_id: int, + workspace_id: int, ) -> SkillsMiddleware | None: if not enabled(flags, "enable_skills"): return None try: skills_factory = build_skills_backend_factory( - search_space_id=search_space_id + workspace_id=workspace_id if filesystem_mode == FilesystemMode.CLOUD else None, ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/stack.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/stack.py index 83053954b..143cceaef 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/stack.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/middleware/stack.py @@ -99,7 +99,7 @@ def build_main_agent_deepagent_middleware( tools: Sequence[BaseTool], backend_resolver: Any, filesystem_mode: FilesystemMode, - search_space_id: int, + workspace_id: int, user_id: str | None, thread_id: int | None, visibility: ChatVisibility, @@ -120,7 +120,7 @@ def build_main_agent_deepagent_middleware( memory_mw = build_memory_mw( user_id=user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, visibility=visibility, ) @@ -232,19 +232,19 @@ def build_main_agent_deepagent_middleware( ), build_knowledge_tree_mw( filesystem_mode=filesystem_mode, - search_space_id=search_space_id, + workspace_id=workspace_id, llm=llm, ), build_kb_persistence_mw( filesystem_mode=filesystem_mode, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, thread_id=thread_id, ), build_skills_mw( flags=flags, filesystem_mode=filesystem_mode, - search_space_id=search_space_id, + workspace_id=workspace_id, ), SurfSenseCheckpointedSubAgentMiddleware( checkpointer=checkpointer, @@ -252,7 +252,7 @@ def build_main_agent_deepagent_middleware( subagents=subagents, system_prompt=None, task_description=TASK_TOOL_DESCRIPTION, - search_space_id=search_space_id, + workspace_id=workspace_id, ), resilience.model_call_limit, resilience.tool_call_limit, @@ -272,14 +272,14 @@ def build_main_agent_deepagent_middleware( build_action_log_mw( flags=flags, thread_id=thread_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ), build_patch_tool_calls_mw(), build_dedup_hitl_mw(tools), *build_plugin_middlewares( flags=flags, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, visibility=visibility, llm=llm, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/plugins/loader.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/plugins/loader.py index c52620d40..ee405fb79 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/plugins/loader.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/plugins/loader.py @@ -57,7 +57,7 @@ class PluginContext(dict): Backed by ``dict`` so plugins can inspect the keys they care about without coupling to a concrete dataclass shape. Required keys: - * ``search_space_id`` (int) + * ``workspace_id`` (int) * ``user_id`` (str | None) * ``thread_visibility`` (:class:`app.db.ChatVisibility`) * ``llm`` (:class:`langchain_core.language_models.BaseChatModel`) @@ -72,13 +72,13 @@ class PluginContext(dict): def build( cls, *, - search_space_id: int, + workspace_id: int, user_id: str | None, thread_visibility: ChatVisibility, llm: BaseChatModel, ) -> PluginContext: return cls( - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, thread_visibility=thread_visibility, llm=llm, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/plugins/year_substituter.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/plugins/year_substituter.py index f6564fe6e..58a59e5ca 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/plugins/year_substituter.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/plugins/year_substituter.py @@ -7,7 +7,7 @@ result. This particular plugin is read-only and only transforms the mutation). The plugin is built as a factory function so the entry-point loader can -inject :class:`PluginContext` (containing the agent's LLM, search-space +inject :class:`PluginContext` (containing the agent's LLM, workspace ID, etc.). The factory signature ``Callable[[PluginContext], AgentMiddleware]`` is the only contract -- SurfSense doesn't define a custom plugin protocol on top of LangChain's diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/agent_cache.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/agent_cache.py index 2d3599de0..7c8d9d3d2 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/agent_cache.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/agent_cache.py @@ -42,7 +42,7 @@ async def build_agent_with_cache( final_system_prompt: str, backend_resolver: Any, filesystem_mode: FilesystemMode, - search_space_id: int, + workspace_id: int, user_id: str | None, thread_id: int | None, visibility: ChatVisibility, @@ -69,7 +69,7 @@ async def build_agent_with_cache( final_system_prompt=final_system_prompt, backend_resolver=backend_resolver, filesystem_mode=filesystem_mode, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, thread_id=thread_id, visibility=visibility, @@ -104,7 +104,7 @@ async def build_agent_with_cache( config_id, None if cross_thread else thread_id, user_id, - search_space_id, + workspace_id, visibility, filesystem_mode, anon_session_id, @@ -120,7 +120,7 @@ async def build_agent_with_cache( sorted(disabled_tools) if disabled_tools else None, # Bound into the generate_image subagent tool at construction time, so it # must key the compiled-agent cache to avoid leaking one automation's - # image model into another with the same config_id/search_space. + # image model into another with the same config_id/workspace. image_gen_model_id_override, ) return await get_cache().get_or_build(cache_key, builder=_build) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/agent_cache_store.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/agent_cache_store.py index 00866adf9..e53483566 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/agent_cache_store.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/agent_cache_store.py @@ -26,7 +26,7 @@ Why a per-thread key (not a global pool) ---------------------------------------- Most middleware in the SurfSense stack captures per-thread state in -``__init__`` closures (``thread_id``, ``user_id``, ``search_space_id``, +``__init__`` closures (``thread_id``, ``user_id``, ``workspace_id``, ``filesystem_mode``, ``mentioned_document_ids``). Cross-thread reuse would silently leak state across users and threads. Keying the cache on ``(llm_config_id, thread_id, ...)`` gives us safe reuse for repeated @@ -34,7 +34,7 @@ turns on the same thread without changing any middleware's behavior. Phase 2 will move those captured fields onto :class:`SurfSenseContextSchema` (read via ``runtime.context``) so the cache can collapse to a single -``(llm_config_id, search_space_id, ...)`` key shared across threads. Until +``(llm_config_id, workspace_id, ...)`` key shared across threads. Until then, per-thread keying is the only safe option. Cache shape @@ -111,7 +111,7 @@ def tools_signature( * A tool is added or removed from the bound list (built-in toggles, MCP tools loaded for the user changes, gating rules flip, etc.). - * The available connectors / document types for the search space + * The available connectors / document types for the workspace change (new connector added, last connector removed, new document type indexed). Connector gating derives disabled tools from ``available_connectors``, so the tool surface is technically already diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/connector_searchable_types.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/connector_searchable_types.py index be193be04..09fa0756a 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/connector_searchable_types.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/connector_searchable_types.py @@ -1,7 +1,7 @@ """Map configured connectors to the searchable document/connector types. This is agent-agnostic infrastructure shared by every agent factory (single- -and multi-agent). It translates the connectors a search space has enabled into +and multi-agent). It translates the connectors a workspace has enabled into the set of searchable type strings that pre-search middleware and ``web_search`` understand, and always layers in the document types that exist independently of any connector (uploads, notes, extension captures, YouTube). diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/factory.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/factory.py index d823a5a06..5cc27b74b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/factory.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/runtime/factory.py @@ -58,7 +58,7 @@ _perf_log = get_perf_logger() async def create_multi_agent_chat_deep_agent( llm: BaseChatModel, - search_space_id: int, + workspace_id: int, db_session: AsyncSession, connector_service: ConnectorService, checkpointer: Checkpointer, @@ -78,9 +78,9 @@ async def create_multi_agent_chat_deep_agent( ): """Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled. - ``image_gen_model_id`` overrides the search space's image model for + ``image_gen_model_id`` overrides the workspace's image model for this invocation (used by automations to run on their captured model). When - ``None``, the ``generate_image`` tool resolves the live search-space pref. + ``None``, the ``generate_image`` tool resolves the live workspace pref. """ _t_agent_total = time.perf_counter() @@ -89,7 +89,7 @@ async def create_multi_agent_chat_deep_agent( filesystem_selection = filesystem_selection or FilesystemSelection() backend_resolver = build_backend_resolver( filesystem_selection, - search_space_id=search_space_id + workspace_id=workspace_id if filesystem_selection.mode == FilesystemMode.CLOUD else None, ) @@ -100,12 +100,12 @@ async def create_multi_agent_chat_deep_agent( _t0 = time.perf_counter() try: connector_types = await connector_service.get_available_connectors( - search_space_id + workspace_id ) available_connectors = map_connectors_to_searchable_types(connector_types) available_document_types = await connector_service.get_available_document_types( - search_space_id + workspace_id ) except Exception as e: @@ -136,7 +136,7 @@ async def create_multi_agent_chat_deep_agent( ) dependencies: dict[str, Any] = { - "search_space_id": search_space_id, + "workspace_id": workspace_id, "db_session": db_session, "connector_service": connector_service, "firecrawl_api_key": firecrawl_api_key, @@ -156,7 +156,7 @@ async def create_multi_agent_chat_deep_agent( _t0 = time.perf_counter() try: mcp_tools_by_agent = await load_mcp_tools_by_connector( - db_session, search_space_id + db_session, workspace_id ) except Exception as e: # Degrade to builtins-only rather than aborting the turn: a transient @@ -193,7 +193,7 @@ async def create_multi_agent_chat_deep_agent( user_allowlist_by_subagent = await fetch_user_allowlist_rulesets( db_session, user_id=user_uuid, - search_space_id=search_space_id, + workspace_id=workspace_id, ) except Exception as e: logging.warning( @@ -291,7 +291,7 @@ async def create_multi_agent_chat_deep_agent( final_system_prompt=final_system_prompt, backend_resolver=backend_resolver, filesystem_mode=filesystem_selection.mode, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, thread_id=thread_id, visibility=visibility, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/skills/backends.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/skills/backends.py index 31620fe9b..ad541563a 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/skills/backends.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/skills/backends.py @@ -17,12 +17,12 @@ Two backends are provided: * :class:`BuiltinSkillsBackend` — disk-backed read of bundled skills from ``app/agents/shared/skills/builtin/``. -* :class:`SearchSpaceSkillsBackend` — a thin read-only wrapper over +* :class:`WorkspaceSkillsBackend` — a thin read-only wrapper over :class:`KBPostgresBackend` that filters notes under the privileged folder ``/documents/_skills/``. Both backends are intentionally read-only: skill authoring happens out of band -(via filesystem or a search-space-admin route), so we never expose +(via filesystem or a workspace-admin route), so we never expose ``write`` / ``edit`` / ``upload_files``. The base class' ``NotImplementedError`` gives a clean failure mode if anything tries. """ @@ -181,12 +181,12 @@ class BuiltinSkillsBackend(BackendProtocol): return responses -class SearchSpaceSkillsBackend(BackendProtocol): - """Read-only view of search-space-authored skills. +class WorkspaceSkillsBackend(BackendProtocol): + """Read-only view of workspace-authored skills. Wraps a :class:`KBPostgresBackend` and only ever reads under the privileged folder ``/documents/_skills/`` (configurable). The folder is intended to be - writable only by search-space admins; this backend never writes. + writable only by workspace admins; this backend never writes. The skills middleware expects a layout like:: @@ -236,14 +236,14 @@ class SearchSpaceSkillsBackend(BackendProtocol): # path falls back to ``asyncio.to_thread(...)`` in the base class. We # keep this stub to satisfy abstract resolution; the middleware calls # ``als_info``. - raise NotImplementedError("SearchSpaceSkillsBackend is async-only") + raise NotImplementedError("WorkspaceSkillsBackend is async-only") async def als_info(self, path: str) -> list[FileInfo]: kb_path = self._to_kb(path) try: infos = await self._kb.als_info(kb_path) except Exception as exc: # pragma: no cover - defensive - logger.warning("SearchSpaceSkillsBackend.als_info failed: %s", exc) + logger.warning("WorkspaceSkillsBackend.als_info failed: %s", exc) return [] remapped: list[FileInfo] = [] for info in infos: @@ -254,7 +254,7 @@ class SearchSpaceSkillsBackend(BackendProtocol): return remapped def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: - raise NotImplementedError("SearchSpaceSkillsBackend is async-only") + raise NotImplementedError("WorkspaceSkillsBackend is async-only") async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: kb_paths = [self._to_kb(p) for p in paths] @@ -274,16 +274,16 @@ SKILLS_SPACE_PREFIX = "/skills/space/" def build_skills_backend_factory( *, builtin_root: Path | str | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, ) -> Callable[[ToolRuntime], BackendProtocol]: """Return a runtime-aware factory for the skills :class:`CompositeBackend`. - When ``search_space_id`` is provided the composite includes a - :class:`SearchSpaceSkillsBackend` route at ``/skills/space/`` over a fresh + When ``workspace_id`` is provided the composite includes a + :class:`WorkspaceSkillsBackend` route at ``/skills/space/`` over a fresh per-runtime :class:`KBPostgresBackend`, mirroring how :func:`build_backend_resolver` constructs the main filesystem backend. - When ``search_space_id`` is ``None`` (e.g., desktop-local mode or unit + When ``workspace_id`` is ``None`` (e.g., desktop-local mode or unit tests) only the bundled :class:`BuiltinSkillsBackend` is exposed. Returning a factory rather than a fixed instance is intentional: the @@ -293,7 +293,7 @@ def build_skills_backend_factory( """ builtin = BuiltinSkillsBackend(builtin_root) - if search_space_id is None: + if workspace_id is None: def _factory_builtin_only(runtime: ToolRuntime) -> BackendProtocol: # Default StateBackend is intentionally inert: any path outside the @@ -314,8 +314,8 @@ def build_skills_backend_factory( KBPostgresBackend, ) - kb = KBPostgresBackend(search_space_id, runtime) - space = SearchSpaceSkillsBackend(kb) + kb = KBPostgresBackend(workspace_id, runtime) + space = WorkspaceSkillsBackend(kb) return CompositeBackend( default=StateBackend(runtime), routes={ @@ -336,7 +336,7 @@ __all__ = [ "SKILLS_BUILTIN_PREFIX", "SKILLS_SPACE_PREFIX", "BuiltinSkillsBackend", - "SearchSpaceSkillsBackend", + "WorkspaceSkillsBackend", "build_skills_backend_factory", "default_skills_sources", ] diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/description.md b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/description.md index 8459f9e7a..2d1c5af9b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/description.md +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/tools/update_memory/team/description.md @@ -1,5 +1,5 @@ - `update_memory` — Curate the team's **shared** long-term memory document - for this search space. + for this workspace. - The current memory (if any) appears in `` with usage vs limit. - Call when a team member asks to remember or forget something, or when the conversation surfaces durable team decisions, conventions, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py index c1122b681..c00692185 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/create.py @@ -45,14 +45,14 @@ _JSON_FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) def create_create_automation_tool( *, - search_space_id: int, + workspace_id: int, user_id: str | UUID, llm: Any, auth_context: AuthContext | None = None, ): """Factory for the ``create_automation`` tool. - ``search_space_id`` is injected from the chat session (the model never + ``workspace_id`` is injected from the chat session (the model never has to guess it). ``llm`` is the drafting sub-model — we reuse the main agent's LLM and tag the call so it's identifiable in traces. A fresh ``AsyncSession`` is opened per call to avoid stale sessions on @@ -101,12 +101,12 @@ def create_create_automation_tool( """ # Models are chosen per-automation on the approval card (premium/BYOK # selectors) and validated when persisted by ``AutomationService.create`` - # — so there's no fail-fast search-space eligibility gate here. The - # search space's current chat/role model selection no longer constrains + # — so there's no fail-fast workspace eligibility gate here. The + # workspace's current chat/role model selection no longer constrains # whether an automation can be drafted or saved. # --- 1. Draft via sub-LLM --- - prompt = build_draft_prompt(search_space_id=search_space_id, intent=intent) + prompt = build_draft_prompt(workspace_id=workspace_id, intent=intent) try: response = await llm.ainvoke( [HumanMessage(content=prompt)], @@ -125,8 +125,8 @@ def create_create_automation_tool( "raw": raw_text, } - # search_space_id is injected here so the sub-LLM never has to guess. - draft["search_space_id"] = search_space_id + # workspace_id is injected here so the sub-LLM never has to guess. + draft["workspace_id"] = workspace_id try: validated_draft = AutomationCreate.model_validate(draft) except ValidationError as exc: @@ -139,14 +139,14 @@ def create_create_automation_tool( # --- 2. HITL approval card --- try: card_params = validated_draft.model_dump(mode="json", by_alias=True) - # search_space_id is session-scoped, not user-editable. - card_params.pop("search_space_id", None) + # workspace_id is session-scoped, not user-editable. + card_params.pop("workspace_id", None) result = request_approval( action_type="automation_create", tool_name="create_automation", params=card_params, - context={"search_space_id": search_space_id}, + context={"workspace_id": workspace_id}, tool_call_id=runtime.tool_call_id, ) @@ -157,7 +157,7 @@ def create_create_automation_tool( } # --- 3. Persist (re-validate in case the user edited) --- - final_payload = {**result.params, "search_space_id": search_space_id} + final_payload = {**result.params, "workspace_id": workspace_id} try: final_validated = AutomationCreate.model_validate(final_payload) except ValidationError as exc: diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/prompt.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/prompt.py index 09854aa2e..66ba0183c 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/prompt.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/automation/prompt.py @@ -34,7 +34,7 @@ into a SINGLE JSON object matching the AutomationCreate schema. Output ONLY that JSON object — no prose, no markdown fence, no commentary. Current UTC time (for cron context): {now} -Target search_space_id: {search_space_id} +Target workspace_id: {workspace_id} """ @@ -165,12 +165,12 @@ User intent: """ -def build_draft_prompt(*, search_space_id: int, intent: str) -> str: +def build_draft_prompt(*, workspace_id: int, intent: str) -> str: """Render the drafting sub-LLM system prompt for the given intent.""" return ( _HEADER.format( now=datetime.now(UTC).isoformat(timespec="seconds"), - search_space_id=search_space_id, + workspace_id=workspace_id, ) + _SCHEMA + _FEW_SHOTS diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/registry.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/registry.py index bdfa67c79..23ea8b8d6 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/registry.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/registry.py @@ -37,7 +37,7 @@ def _build_scrape_webpage_tool(deps: dict[str, Any]) -> BaseTool: def _build_web_search_tool(deps: dict[str, Any]) -> BaseTool: return create_web_search_tool( - search_space_id=deps.get("search_space_id"), + workspace_id=deps.get("workspace_id"), available_connectors=deps.get("available_connectors"), ) @@ -49,7 +49,7 @@ def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool: from .automation import create_create_automation_tool return create_create_automation_tool( - search_space_id=deps["search_space_id"], + workspace_id=deps["workspace_id"], user_id=deps["user_id"], auth_context=deps.get("auth_context"), llm=deps["llm"], @@ -59,7 +59,7 @@ def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool: def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool: if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE: return create_update_team_memory_tool( - search_space_id=deps["search_space_id"], + workspace_id=deps["workspace_id"], db_session=deps["db_session"], llm=deps.get("llm"), ) @@ -80,11 +80,11 @@ _MAIN_AGENT_TOOL_FACTORIES: dict[ "web_search": (_build_web_search_tool, ()), "create_automation": ( _build_create_automation_tool, - ("search_space_id", "user_id", "llm"), + ("workspace_id", "user_id", "llm"), ), "update_memory": ( _build_update_memory_tool, - ("user_id", "search_space_id", "db_session", "thread_visibility", "llm"), + ("user_id", "workspace_id", "db_session", "thread_visibility", "llm"), ), } diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/update_memory.py b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/update_memory.py index 78a65201b..333d10e4b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/update_memory.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/tools/update_memory.py @@ -53,7 +53,7 @@ def create_update_memory_tool( def create_update_team_memory_tool( - search_space_id: int, + workspace_id: int, db_session: AsyncSession, llm: Any | None = None, ): @@ -62,7 +62,7 @@ def create_update_team_memory_tool( @tool async def update_memory(updated_memory: str) -> dict[str, Any]: - """Update the team's shared memory document for this search space. + """Update the team's shared memory document for this workspace. The current team memory is shown in . Pass the FULL updated markdown document, not a diff. @@ -71,7 +71,7 @@ def create_update_team_memory_tool( async with async_session_maker() as db_session: result = await save_memory( scope=MemoryScope.TEAM, - target_id=search_space_id, + target_id=workspace_id, content=updated_memory, session=db_session, llm=llm, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/backends/kb_postgres.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/backends/kb_postgres.py index cb0f4cc69..d21ce03c6 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/backends/kb_postgres.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/backends/kb_postgres.py @@ -120,8 +120,8 @@ class KBPostgresBackend(BackendProtocol): _IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) - def __init__(self, search_space_id: int, runtime: ToolRuntime) -> None: - self.search_space_id = search_space_id + def __init__(self, workspace_id: int, runtime: ToolRuntime) -> None: + self.workspace_id = workspace_id self.runtime = runtime @property @@ -405,7 +405,7 @@ class KBPostgresBackend(BackendProtocol): if not normalized_path.startswith(DOCUMENTS_ROOT): return [], set() - index = await build_path_index(session, self.search_space_id) + index = await build_path_index(session, self.workspace_id) target_folder_id: int | None = None if normalized_path != DOCUMENTS_ROOT: target_path = normalized_path @@ -418,7 +418,7 @@ class KBPostgresBackend(BackendProtocol): result = await session.execute( select(Document.id, Document.title, Document.folder_id, Document.updated_at) - .where(Document.search_space_id == self.search_space_id) + .where(Document.workspace_id == self.workspace_id) .where( Document.folder_id == target_folder_id if target_folder_id is not None @@ -519,7 +519,7 @@ class KBPostgresBackend(BackendProtocol): async with shielded_async_session() as session: document_row = await virtual_path_to_doc( session, - search_space_id=self.search_space_id, + workspace_id=self.workspace_id, virtual_path=path, ) if document_row is None: @@ -667,10 +667,10 @@ class KBPostgresBackend(BackendProtocol): if normalized.startswith(DOCUMENTS_ROOT) or normalized == "/": try: async with shielded_async_session() as session: - index = await build_path_index(session, self.search_space_id) + index = await build_path_index(session, self.workspace_id) rows = await session.execute( select(Document.id, Document.title, Document.folder_id).where( - Document.search_space_id == self.search_space_id + Document.workspace_id == self.workspace_id ) ) for row in rows.all(): @@ -747,11 +747,11 @@ class KBPostgresBackend(BackendProtocol): if normalized.startswith(DOCUMENTS_ROOT) or normalized == "/": try: async with shielded_async_session() as session: - index = await build_path_index(session, self.search_space_id) + index = await build_path_index(session, self.workspace_id) sub = ( select(Chunk.document_id, Chunk.id, Chunk.content) .join(Document, Document.id == Chunk.document_id) - .where(Document.search_space_id == self.search_space_id) + .where(Document.workspace_id == self.workspace_id) .where(Chunk.content.ilike(f"%{pattern}%")) .order_by(Chunk.document_id, Chunk.position, Chunk.id) ) @@ -849,14 +849,14 @@ class KBPostgresBackend(BackendProtocol): try: async with shielded_async_session() as session: - index = await build_path_index(session, self.search_space_id) + index = await build_path_index(session, self.workspace_id) doc_rows_raw = await session.execute( select( Document.id, Document.title, Document.folder_id, Document.updated_at, - ).where(Document.search_space_id == self.search_space_id) + ).where(Document.workspace_id == self.workspace_id) ) doc_rows = list(doc_rows_raw.all()) except Exception as exc: # pragma: no cover diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/backends/resolver.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/backends/resolver.py index 4553df7ff..4b8c3dc6f 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/backends/resolver.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/backends/resolver.py @@ -31,14 +31,14 @@ def _cached_multi_root_backend( def build_backend_resolver( selection: FilesystemSelection, *, - search_space_id: int | None = None, + workspace_id: int | None = None, ) -> Callable[[ToolRuntime], BackendProtocol]: """Create deepagents backend resolver for the selected filesystem mode. In cloud mode the resolver returns a fresh :class:`KBPostgresBackend` bound to the current ``runtime`` so the backend can read staging state (``staged_dirs``, ``pending_moves``, ``files`` cache, ``kb_anon_doc``) - for each tool call. When no ``search_space_id`` + for each tool call. When no ``workspace_id`` is provided, the resolver falls back to :class:`StateBackend` (used by sub-agents and tests that don't need DB-backed reads). @@ -55,10 +55,10 @@ def build_backend_resolver( return _resolve_local - if search_space_id is not None: + if workspace_id is not None: def _resolve_kb(runtime: ToolRuntime) -> BackendProtocol: - return KBPostgresBackend(search_space_id, runtime) + return KBPostgresBackend(workspace_id, runtime) return _resolve_kb diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/index.py index 91bc4db7c..e4d2fdf78 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/index.py @@ -13,7 +13,7 @@ def build_filesystem_mw( *, backend_resolver: Any, filesystem_mode: FilesystemMode, - search_space_id: int, + workspace_id: int, user_id: str | None, thread_id: int | None, read_only: bool = False, @@ -21,7 +21,7 @@ def build_filesystem_mw( return SurfSenseFilesystemMiddleware( backend=backend_resolver, filesystem_mode=filesystem_mode, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=user_id, thread_id=thread_id, read_only=read_only, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/middleware/middleware.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/middleware/middleware.py index c3b06ff12..95f217df6 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/middleware/middleware.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/middleware/filesystem/middleware/middleware.py @@ -49,14 +49,14 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): *, backend: Any = None, filesystem_mode: FilesystemMode = FilesystemMode.CLOUD, - search_space_id: int | None = None, + workspace_id: int | None = None, created_by_id: str | None = None, thread_id: int | str | None = None, tool_token_limit_before_evict: int | None = 20000, read_only: bool = False, ) -> None: self._filesystem_mode = filesystem_mode - self._search_space_id = search_space_id + self._workspace_id = workspace_id self._created_by_id = created_by_id self._thread_id = thread_id self._read_only = read_only diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/retrieval/hybrid_search.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/retrieval/hybrid_search.py index cc200b3a6..70fe5d47f 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/retrieval/hybrid_search.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/retrieval/hybrid_search.py @@ -31,7 +31,7 @@ _SURFACE = "chunks" async def search_chunks( db_session: AsyncSession, *, - search_space_id: int, + workspace_id: int, query: str, scope: SearchScope, top_k: int, @@ -44,14 +44,14 @@ async def search_chunks( """ started = time.perf_counter() with otel.kb_search_span( - search_space_id=search_space_id, + workspace_id=workspace_id, query_chars=len(query), extra={"search.surface": _SURFACE, "search.mode": "hybrid"}, ) as span: try: documents = await _search( db_session, - search_space_id=search_space_id, + workspace_id=workspace_id, query=query, scope=scope, top_k=top_k, @@ -60,14 +60,14 @@ async def search_chunks( finally: elapsed_ms = (time.perf_counter() - started) * 1000 metrics.record_kb_search_duration( - elapsed_ms, search_space_id=search_space_id, surface=_SURFACE + elapsed_ms, workspace_id=workspace_id, surface=_SURFACE ) span.set_attribute("result.count", len(documents)) get_perf_logger().info( "[chunk_search] hybrid in %.3fs docs=%d space=%d", elapsed_ms / 1000, len(documents), - search_space_id, + workspace_id, ) return documents @@ -75,7 +75,7 @@ async def search_chunks( async def _search( db_session: AsyncSession, *, - search_space_id: int, + workspace_id: int, query: str, scope: SearchScope, top_k: int, @@ -91,7 +91,7 @@ async def _search( config.embedding_model_instance.embed, query ) - conditions = _base_conditions(search_space_id, scope, document_types) + conditions = _base_conditions(workspace_id, scope, document_types) rows = await _fused_chunks( db_session, query=query, @@ -116,13 +116,13 @@ def _resolve_document_types( def _base_conditions( - search_space_id: int, + workspace_id: int, scope: SearchScope, document_types: list[DocumentType] | None, ) -> list: """Filters shared by both search legs.""" conditions = [ - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, func.coalesce(Document.status["state"].astext, "ready") != "deleting", ] if document_types: diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/retrieval/service.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/retrieval/service.py index e9cfa18dd..e8e39a918 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/retrieval/service.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/retrieval/service.py @@ -29,7 +29,7 @@ _DEFAULT_TOP_K = 10 async def search_knowledge_base_context( db_session: AsyncSession, *, - search_space_id: int, + workspace_id: int, query: str, registry: CitationRegistry, scope: SearchScope | None = None, @@ -42,7 +42,7 @@ async def search_knowledge_base_context( """ hits = await search_chunks( db_session, - search_space_id=search_space_id, + workspace_id=workspace_id, query=query, scope=scope or SearchScope(), top_k=top_k, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/hitl.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/hitl.py index 9b16e1a4c..16c934187 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/hitl.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/hitl.py @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) # artifact in the user's own workspace with no external visibility (drafts # aren't sent; new files aren't shared). They still call ``request_approval``, # which returns ``decision_type="auto_approved"`` without firing an interrupt. -# Per-search-space ``agent_permission_rules`` can re-enable prompting. +# Per-workspace ``agent_permission_rules`` can re-enable prompting. DEFAULT_AUTO_APPROVED_TOOLS: frozenset[str] = frozenset( { "create_gmail_draft", diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/mcp/cache.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/mcp/cache.py index d088fac0b..83fdc1fa2 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/mcp/cache.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/mcp/cache.py @@ -99,11 +99,11 @@ async def write_cached_tools( def refresh_mcp_tools_cache_for_connector( connector_id: int, - search_space_id: int, + workspace_id: int, ) -> None: """Maintain the MCP tool cache after a single-connector lifecycle event. - Synchronously evicts the in-process LRU for the connector's search space + Synchronously evicts the in-process LRU for the connector's workspace (LRU keys are per-space, so eviction cannot be scoped finer), then schedules a background live discovery for this connector alone so its persisted ``cached_tools`` row is refreshed before the next user query. @@ -116,11 +116,11 @@ def refresh_mcp_tools_cache_for_connector( invalidate_mcp_tools_cache, ) - invalidate_mcp_tools_cache(search_space_id) + invalidate_mcp_tools_cache(workspace_id) except Exception: logger.debug( "MCP in-process cache eviction skipped for space %d", - search_space_id, + workspace_id, exc_info=True, ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/mcp/tool.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/mcp/tool.py index a1240391b..2d9d0cf06 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/mcp/tool.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/tools/mcp/tool.py @@ -56,7 +56,7 @@ _MCP_CACHE_MAX_SIZE = 50 _MCP_DISCOVERY_TIMEOUT_SECONDS = 30 _TOOL_CALL_MAX_RETRIES = 3 _TOOL_CALL_RETRY_DELAY = 1.5 # seconds, doubles per attempt -# Keyed by ``(search_space_id, bypass_internal_hitl)`` so single-agent and +# Keyed by ``(workspace_id, bypass_internal_hitl)`` so single-agent and # multi-agent paths cannot share tool closures with different HITL wiring. _MCPCacheKey = tuple[int, bool] _mcp_tools_cache: dict[_MCPCacheKey, tuple[float, list[StructuredTool]]] = {} @@ -814,7 +814,7 @@ async def _refresh_connector_token( await session.commit() await session.refresh(connector) - invalidate_mcp_tools_cache(connector.search_space_id) + invalidate_mcp_tools_cache(connector.workspace_id) return new_access @@ -990,7 +990,7 @@ async def _mark_connector_auth_expired(connector_id: int) -> None: "Marked MCP connector %s as auth_expired after unrecoverable 401", connector_id, ) - invalidate_mcp_tools_cache(connector.search_space_id) + invalidate_mcp_tools_cache(connector.workspace_id) except Exception: logger.warning( @@ -1000,10 +1000,10 @@ async def _mark_connector_auth_expired(connector_id: int) -> None: ) -def invalidate_mcp_tools_cache(search_space_id: int | None = None) -> None: +def invalidate_mcp_tools_cache(workspace_id: int | None = None) -> None: """Invalidate cached MCP tools (both ``bypass_internal_hitl`` variants together).""" - if search_space_id is not None: - for key in [k for k in _mcp_tools_cache if k[0] == search_space_id]: + if workspace_id is not None: + for key in [k for k in _mcp_tools_cache if k[0] == workspace_id]: _mcp_tools_cache.pop(key, None) else: _mcp_tools_cache.clear() @@ -1099,27 +1099,27 @@ async def discover_single_mcp_connector(connector_id: int) -> None: async def load_mcp_tools( session: AsyncSession, - search_space_id: int, + workspace_id: int, *, bypass_internal_hitl: bool = False, ) -> list[StructuredTool]: """Load all MCP tools from the user's active MCP server connectors. - Results are cached per ``(search_space_id, bypass_internal_hitl)`` for up + Results are cached per ``(workspace_id, bypass_internal_hitl)`` for up to 5 minutes; bypass is keyed because each variant builds a different tool closure (with vs. without the in-wrapper ``request_approval`` gate). """ _evict_expired_mcp_cache() now = time.monotonic() - cache_key: _MCPCacheKey = (search_space_id, bypass_internal_hitl) + cache_key: _MCPCacheKey = (workspace_id, bypass_internal_hitl) cached = _mcp_tools_cache.get(cache_key) if cached is not None: cached_at, cached_tools = cached if now - cached_at < _MCP_CACHE_TTL_SECONDS: logger.info( - "Using cached MCP tools for search space %s (%d tools, age=%.0fs, bypass_hitl=%s)", - search_space_id, + "Using cached MCP tools for workspace %s (%d tools, age=%.0fs, bypass_hitl=%s)", + workspace_id, len(cached_tools), now - cached_at, bypass_internal_hitl, @@ -1132,7 +1132,7 @@ async def load_mcp_tools( # Cast JSON -> JSONB so we can use has_key to filter by the presence of "server_config". result = await session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, cast(SearchSourceConnector.config, JSONB).has_key("server_config"), ), ) @@ -1322,9 +1322,9 @@ async def load_mcp_tools( del _mcp_tools_cache[oldest_key] logger.info( - "Loaded %d MCP tools for search space %d (bypass_hitl=%s)", + "Loaded %d MCP tools for workspace %d (bypass_hitl=%s)", len(tools), - search_space_id, + workspace_id, bypass_internal_hitl, ) return tools diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py index c481c6c3d..f9158335b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py @@ -1,4 +1,4 @@ -"""Image generation via litellm; resolves model config from the search space and returns UI-ready payloads.""" +"""Image generation via litellm; resolves model config from the workspace and returns UI-ready payloads.""" import hashlib import logging @@ -18,7 +18,7 @@ from app.config import config from app.db import ( ImageGeneration, Model, - SearchSpace, + Workspace, shielded_async_session, ) from app.services.auto_model_pin_service import ( @@ -48,14 +48,14 @@ def _get_global_connection(connection_id: int) -> dict | None: def create_generate_image_tool( - search_space_id: int, + workspace_id: int, db_session: AsyncSession, image_gen_model_id_override: int | None = None, ): - """Create ``generate_image`` with bound search space; DB work uses a per-call session. + """Create ``generate_image`` with bound workspace; DB work uses a per-call session. ``image_gen_model_id_override``: when set (automations running on a - captured model), use this model id instead of reading the search space's + captured model), use this model id instead of reading the workspace's live ``image_gen_model_id``. """ del db_session # tool uses a fresh per-call session instead @@ -102,22 +102,22 @@ def create_generate_image_tool( # autoflushes from a concurrent writer poison this tool too. async with shielded_async_session() as session: result = await session.execute( - select(SearchSpace).filter(SearchSpace.id == search_space_id) + select(Workspace).filter(Workspace.id == workspace_id) ) - search_space = result.scalars().first() - if not search_space: + workspace = result.scalars().first() + if not workspace: return _failed( - {"error": "Search space not found"}, - error="Search space not found", + {"error": "Workspace not found"}, + error="Workspace not found", ) if image_gen_model_id_override is not None: # Automation run: use the captured image model, insulated from - # later search-space changes. No search-space read needed. + # later workspace changes. No workspace read needed. config_id = image_gen_model_id_override or IMAGE_GEN_AUTO_MODE_ID else: config_id = ( - search_space.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID + workspace.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID ) # size/quality/style are intentionally omitted: valid values @@ -129,8 +129,8 @@ def create_generate_image_tool( 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: @@ -140,7 +140,7 @@ def create_generate_image_tool( ) return _failed({"error": err}, error=err) config_id = int( - choose_auto_model_candidate(candidates, search_space_id)["id"] + choose_auto_model_candidate(candidates, workspace_id)["id"] ) provider_base_url: str | None = None @@ -186,14 +186,14 @@ def create_generate_image_tool( return _failed({"error": err}, error=err) conn = db_model.connection if ( - conn.search_space_id is not None - and conn.search_space_id != search_space_id + conn.workspace_id is not None + and conn.workspace_id != workspace_id ): err = f"Image generation model {config_id} not found" return _failed({"error": err}, error=err) if ( conn.user_id is not None - and conn.user_id != search_space.user_id + and conn.user_id != workspace.user_id ): err = f"Image generation model {config_id} not found" return _failed({"error": err}, error=err) @@ -225,7 +225,7 @@ def create_generate_image_tool( n=n, image_gen_model_id=config_id, response_data=response_dict, - search_space_id=search_space_id, + workspace_id=workspace_id, access_token=access_token, ) session.add(db_image_gen) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/index.py index 8de95f2df..6db66a157 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/index.py @@ -28,28 +28,28 @@ def load_tools( d = {**(dependencies or {}), **kwargs} return [ create_generate_podcast_tool( - search_space_id=d["search_space_id"], + workspace_id=d["workspace_id"], db_session=d["db_session"], thread_id=d["thread_id"], ), create_generate_video_presentation_tool( - search_space_id=d["search_space_id"], + workspace_id=d["workspace_id"], db_session=d["db_session"], thread_id=d["thread_id"], ), create_generate_report_tool( - search_space_id=d["search_space_id"], + workspace_id=d["workspace_id"], thread_id=d["thread_id"], connector_service=d.get("connector_service"), available_connectors=d.get("available_connectors"), available_document_types=d.get("available_document_types"), ), create_generate_resume_tool( - search_space_id=d["search_space_id"], + workspace_id=d["workspace_id"], thread_id=d["thread_id"], ), create_generate_image_tool( - search_space_id=d["search_space_id"], + workspace_id=d["workspace_id"], db_session=d["db_session"], image_gen_model_id_override=d.get("image_gen_model_id_override"), ), diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py index d8d28ceb1..fb71a3a0d 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py @@ -28,11 +28,11 @@ logger = logging.getLogger(__name__) def create_generate_podcast_tool( - search_space_id: int, + workspace_id: int, db_session: AsyncSession, thread_id: int | None = None, ): - """Create ``generate_podcast`` with bound search space and thread; DB writes use a tool-local session.""" + """Create ``generate_podcast`` with bound workspace and thread; DB writes use a tool-local session.""" del db_session # writes use a fresh tool-local session, see below @tool @@ -77,13 +77,13 @@ def create_generate_podcast_tool( service = PodcastService(session) podcast = await service.create( title=podcast_title, - search_space_id=search_space_id, + workspace_id=workspace_id, thread_id=resolve_root_thread_id(runtime, thread_id), ) podcast.source_content = source_content spec = await propose_brief( session, - search_space_id=search_space_id, + workspace_id=workspace_id, focus=user_prompt, ) await service.attach_brief(podcast, spec) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/report.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/report.py index c80a2a565..e95d6f61e 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/report.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/report.py @@ -31,7 +31,7 @@ def _report_search_types( """Build the document-type scope for the shared KB search. ``None`` means "search every indexed type"; a tuple narrows the scope to the - connectors/document types the search space actually has. + connectors/document types the workspace actually has. """ types: set[str] = set() if available_document_types: @@ -561,7 +561,7 @@ async def _revise_with_sections( def create_generate_report_tool( - search_space_id: int, + workspace_id: int, thread_id: int | None = None, connector_service: ConnectorService | None = None, available_connectors: list[str] | None = None, @@ -728,7 +728,7 @@ def create_generate_report_tool( "error_message": error_msg, }, report_style=report_style, - search_space_id=search_space_id, + workspace_id=workspace_id, thread_id=resolve_root_thread_id(runtime, thread_id), report_group_id=report_group_id, ) @@ -769,7 +769,7 @@ def create_generate_report_tool( "creating standalone report" ) - llm = await get_agent_llm(read_session, search_space_id) + llm = await get_agent_llm(read_session, workspace_id) if not llm: error_msg = ( @@ -846,7 +846,7 @@ def create_generate_report_tool( async with shielded_async_session() as kb_session: return await search_chunks( kb_session, - search_space_id=search_space_id, + workspace_id=workspace_id, query=q, scope=scope, top_k=10, @@ -1044,7 +1044,7 @@ def create_generate_report_tool( content=report_content, report_metadata=metadata, report_style=report_style, - search_space_id=search_space_id, + workspace_id=workspace_id, thread_id=resolve_root_thread_id(runtime, thread_id), report_group_id=report_group_id, ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py index 35dc996a1..c3475fb45 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py @@ -421,7 +421,7 @@ def _validate_max_pages(max_pages: int) -> int: def create_generate_resume_tool( - search_space_id: int, + workspace_id: int, thread_id: int | None = None, ): """ @@ -531,7 +531,7 @@ def create_generate_resume_tool( "error_message": error_msg, }, report_style="resume", - search_space_id=search_space_id, + workspace_id=workspace_id, thread_id=resolve_root_thread_id(runtime, thread_id), report_group_id=report_group_id, ) @@ -581,7 +581,7 @@ def create_generate_resume_tool( f"(group {report_group_id})" ) - llm = await get_agent_llm(read_session, search_space_id) + llm = await get_agent_llm(read_session, workspace_id) if not llm: error_msg = ( @@ -819,7 +819,7 @@ def create_generate_resume_tool( content_type="typst", report_metadata=metadata, report_style="resume", - search_space_id=search_space_id, + workspace_id=workspace_id, thread_id=resolve_root_thread_id(runtime, thread_id), report_group_id=report_group_id, ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py index f0fcf6e73..3797ac3d8 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py @@ -31,11 +31,11 @@ logger = logging.getLogger(__name__) def create_generate_video_presentation_tool( - search_space_id: int, + workspace_id: int, db_session: AsyncSession, thread_id: int | None = None, ): - """Create ``generate_video_presentation`` with bound search space and thread; writes use a tool-local session.""" + """Create ``generate_video_presentation`` with bound workspace and thread; writes use a tool-local session.""" del db_session # writes use a fresh tool-local session, see below @tool @@ -60,7 +60,7 @@ def create_generate_video_presentation_tool( video_pres = VideoPresentation( title=video_title, status=VideoPresentationStatus.PENDING, - search_space_id=search_space_id, + workspace_id=workspace_id, thread_id=resolve_root_thread_id(runtime, thread_id), ) session.add(video_pres) @@ -75,7 +75,7 @@ def create_generate_video_presentation_tool( task = generate_video_presentation_task.delay( video_presentation_id=video_pres_id, source_content=source_content, - search_space_id=search_space_id, + workspace_id=workspace_id, user_prompt=user_prompt, ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/agent.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/agent.py index f193c2404..876593a47 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/agent.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/agent.py @@ -36,7 +36,7 @@ _KB_READONLY_RULESET = Ruleset(origin=READONLY_NAME, rules=[]) def _build_search_knowledge_base_tool(dependencies: dict[str, Any]) -> BaseTool: """Construct the hybrid-RAG ``search_knowledge_base`` tool from shared deps.""" return create_search_knowledge_base_tool( - search_space_id=dependencies["search_space_id"], + workspace_id=dependencies["workspace_id"], available_connectors=dependencies.get("available_connectors"), available_document_types=dependencies.get("available_document_types"), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py index 2684e9db7..f0692ebf6 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py @@ -115,7 +115,7 @@ def build_kb_middleware( fs_mw = build_filesystem_mw( backend_resolver=dependencies["backend_resolver"], filesystem_mode=filesystem_mode, - search_space_id=dependencies["search_space_id"], + workspace_id=dependencies["workspace_id"], user_id=dependencies.get("user_id"), thread_id=dependencies.get("thread_id"), read_only=read_only, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/tools/search_knowledge_base.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/tools/search_knowledge_base.py index c6559adee..ace65a7ab 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/tools/search_knowledge_base.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/knowledge_base/tools/search_knowledge_base.py @@ -89,7 +89,7 @@ def _resolve_mention_pins( async def _build_search_scope( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, document_types: tuple[str, ...] | None, runtime: ToolRuntime[None, SurfSenseFilesystemState], ) -> SearchScope: @@ -97,7 +97,7 @@ async def _build_search_scope( mentioned_document_ids, mentioned_folder_ids = _resolve_mention_pins(runtime) document_ids = await referenced_document_ids( session, - search_space_id=search_space_id, + workspace_id=workspace_id, document_ids=mentioned_document_ids, folder_ids=mentioned_folder_ids, ) @@ -109,13 +109,13 @@ async def _build_search_scope( def create_search_knowledge_base_tool( *, - search_space_id: int, + workspace_id: int, available_connectors: list[str] | None = None, available_document_types: list[str] | None = None, ) -> BaseTool: """Factory for the on-demand ``search_knowledge_base`` tool.""" - _space_id = search_space_id + _space_id = workspace_id _document_types = _search_types(available_connectors, available_document_types) async def _impl( @@ -140,13 +140,13 @@ def create_search_knowledge_base_tool( async with shielded_async_session() as session: scope = await _build_search_scope( session, - search_space_id=_space_id, + workspace_id=_space_id, document_types=_document_types, runtime=runtime, ) hits = await search_chunks( session, - search_space_id=_space_id, + workspace_id=_space_id, query=cleaned_query, scope=scope, top_k=clamped_top_k, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/system_prompt.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/system_prompt.md index b656c5019..5697b1a61 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/system_prompt.md +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/system_prompt.md @@ -6,7 +6,7 @@ Persist durable preferences/facts/instructions with `update_memory` while avoidi -Memory is search-space-scoped; do not assume cross-workspace visibility. +Memory is workspace-scoped; do not assume cross-workspace visibility. diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/tools/index.py index 0afce9dec..e2feee478 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/tools/index.py @@ -23,7 +23,7 @@ def load_tools( if d.get("thread_visibility") == ChatVisibility.SEARCH_SPACE: return [ create_update_team_memory_tool( - search_space_id=d["search_space_id"], + workspace_id=d["workspace_id"], db_session=d["db_session"], llm=d.get("llm"), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py index 67bcc3e06..3361f13ae 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py @@ -51,13 +51,13 @@ def create_update_memory_tool( def create_update_team_memory_tool( - search_space_id: int, + workspace_id: int, db_session: AsyncSession, llm: Any | None = None, ): @tool async def update_memory(updated_memory: str) -> dict[str, Any]: - """Update the team's shared memory document for this search space. + """Update the team's shared memory document for this workspace. The current team memory is shown in . Pass the FULL updated markdown document, not a diff. @@ -65,7 +65,7 @@ def create_update_team_memory_tool( try: result = await save_memory( scope=MemoryScope.TEAM, - target_id=search_space_id, + target_id=workspace_id, content=updated_memory, session=db_session, llm=llm, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/research/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/research/tools/index.py index 5fc2b5699..e1f199cdc 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/research/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/research/tools/index.py @@ -22,7 +22,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} return [ create_web_search_tool( - search_space_id=d.get("search_space_id"), + workspace_id=d.get("workspace_id"), available_connectors=d.get("available_connectors"), ), create_scrape_webpage_tool(firecrawl_api_key=d.get("firecrawl_api_key")), diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py index 91a50b3cc..d233dd16d 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def create_create_calendar_event_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -62,7 +62,7 @@ def create_create_calendar_event_tool( f"create_calendar_event called: summary='{summary}', start='{start_datetime}', end='{end_datetime}'" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Google Calendar tool not properly configured. Please contact support.", @@ -71,7 +71,7 @@ def create_create_calendar_event_tool( try: metadata_service = GoogleCalendarToolMetadataService(db_session) context = await metadata_service.get_creation_context( - search_space_id, user_id + workspace_id, user_id ) if "error" in context: @@ -138,7 +138,7 @@ def create_create_calendar_event_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_calendar_types), ) @@ -153,7 +153,7 @@ def create_create_calendar_event_tool( else: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_calendar_types), ) @@ -318,7 +318,7 @@ def create_create_calendar_event_tool( html_link=created.get("htmlLink"), description=final_description, connector_id=actual_connector_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) if kb_result["status"] == "success": diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py index 7682dae33..ac5e2374b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def create_delete_calendar_event_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -56,7 +56,7 @@ def create_delete_calendar_event_tool( f"delete_calendar_event called: event_ref='{event_title_or_id}', delete_from_kb={delete_from_kb}" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Google Calendar tool not properly configured. Please contact support.", @@ -65,7 +65,7 @@ def create_delete_calendar_event_tool( try: metadata_service = GoogleCalendarToolMetadataService(db_session) context = await metadata_service.get_deletion_context( - search_space_id, user_id, event_title_or_id + workspace_id, user_id, event_title_or_id ) if "error" in context: @@ -143,7 +143,7 @@ def create_delete_calendar_event_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_calendar_types), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/index.py index b087105d4..2d7f38d50 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/index.py @@ -28,7 +28,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py index cf9a015cf..7439b2694 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py @@ -28,7 +28,7 @@ def _to_calendar_boundary(value: str, *, is_end: bool) -> str: def create_search_calendar_events_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -48,7 +48,7 @@ def create_search_calendar_events_tool( Dictionary with status and a list of events including event_id, summary, start, end, location, attendees. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Calendar tool not properly configured.", @@ -59,7 +59,7 @@ def create_search_calendar_events_tool( try: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_CALENDAR_TYPES), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py index 78d3b147b..0ef5be996 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py @@ -32,7 +32,7 @@ def _build_time_body(value: str, context: dict[str, Any] | Any) -> dict[str, str def create_update_calendar_event_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -76,7 +76,7 @@ def create_update_calendar_event_tool( """ logger.info(f"update_calendar_event called: event_ref='{event_title_or_id}'") - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Google Calendar tool not properly configured. Please contact support.", @@ -85,7 +85,7 @@ def create_update_calendar_event_tool( try: metadata_service = GoogleCalendarToolMetadataService(db_session) context = await metadata_service.get_update_context( - search_space_id, user_id, event_title_or_id + workspace_id, user_id, event_title_or_id ) if "error" in context: @@ -176,7 +176,7 @@ def create_update_calendar_event_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_calendar_types), ) @@ -363,7 +363,7 @@ def create_update_calendar_event_tool( document_id=document_id, event_id=final_event_id, connector_id=actual_connector_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) if kb_result["status"] == "success": diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py index 17497eee2..7108f0168 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) def create_create_confluence_page_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, connector_id: int | None = None, ): @@ -44,7 +44,7 @@ def create_create_confluence_page_tool( """ logger.info(f"create_confluence_page called: title='{title}'") - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Confluence tool not properly configured.", @@ -53,7 +53,7 @@ def create_create_confluence_page_tool( try: metadata_service = ConfluenceToolMetadataService(db_session) context = await metadata_service.get_creation_context( - search_space_id, user_id + workspace_id, user_id ) if "error" in context: @@ -103,7 +103,7 @@ def create_create_confluence_page_tool( if actual_connector_id is None: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, @@ -120,7 +120,7 @@ def create_create_confluence_page_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == actual_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, @@ -181,7 +181,7 @@ def create_create_confluence_page_tool( space_id=final_space_id, body_content=final_content, connector_id=actual_connector_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) if kb_result["status"] == "success": diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py index 5e2bd9868..febec227a 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) def create_delete_confluence_page_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, connector_id: int | None = None, ): @@ -45,7 +45,7 @@ def create_delete_confluence_page_tool( f"delete_confluence_page called: page_title_or_id='{page_title_or_id}'" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Confluence tool not properly configured.", @@ -54,7 +54,7 @@ def create_delete_confluence_page_tool( try: metadata_service = ConfluenceToolMetadataService(db_session) context = await metadata_service.get_deletion_context( - search_space_id, user_id, page_title_or_id + workspace_id, user_id, page_title_or_id ) if "error" in context: @@ -112,7 +112,7 @@ def create_delete_confluence_page_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - 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/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/index.py index 73350974e..02a11bc69 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/index.py @@ -26,7 +26,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], "connector_id": d.get("connector_id"), } diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py index 7db9a24dc..42fa04a20 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) def create_update_confluence_page_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, connector_id: int | None = None, ): @@ -47,7 +47,7 @@ def create_update_confluence_page_tool( f"update_confluence_page called: page_title_or_id='{page_title_or_id}'" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Confluence tool not properly configured.", @@ -56,7 +56,7 @@ def create_update_confluence_page_tool( try: metadata_service = ConfluenceToolMetadataService(db_session) context = await metadata_service.get_update_context( - search_space_id, user_id, page_title_or_id + workspace_id, user_id, page_title_or_id ) if "error" in context: @@ -124,7 +124,7 @@ def create_update_confluence_page_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, @@ -183,7 +183,7 @@ def create_update_confluence_page_tool( document_id=final_document_id, page_id=final_page_id, user_id=user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, ) if kb_result["status"] == "success": kb_message_suffix = ( diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/_auth.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/_auth.py index 7636aff71..19d60908b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/_auth.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/_auth.py @@ -12,12 +12,12 @@ DISCORD_API = "https://discord.com/api/v10" async def get_discord_connector( db_session: AsyncSession, - search_space_id: int, + workspace_id: int, user_id: str, ) -> SearchSourceConnector | None: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/index.py index fcef3401a..3404857f1 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/index.py @@ -26,7 +26,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py index 3cc99ac17..68c6b07a0 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) def create_list_discord_channels_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -22,7 +22,7 @@ def create_list_discord_channels_tool( Returns: Dictionary with status and a list of channels (id, name). """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Discord tool not properly configured.", @@ -30,7 +30,7 @@ def create_list_discord_channels_tool( try: connector = await get_discord_connector( - db_session, search_space_id, user_id + db_session, workspace_id, user_id ) if not connector: return {"status": "error", "message": "No Discord connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py index d8bf989a1..200289996 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) def create_read_discord_messages_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -30,7 +30,7 @@ def create_read_discord_messages_tool( Dictionary with status and a list of messages including id, author, content, timestamp. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Discord tool not properly configured.", @@ -40,7 +40,7 @@ def create_read_discord_messages_tool( try: connector = await get_discord_connector( - db_session, search_space_id, user_id + db_session, workspace_id, user_id ) if not connector: return {"status": "error", "message": "No Discord connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/send_message.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/send_message.py index 59ea1de30..d6d6bf1bc 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/send_message.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/discord/tools/send_message.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) def create_send_discord_message_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -36,7 +36,7 @@ def create_send_discord_message_tool( IMPORTANT: - If status is "rejected", the user explicitly declined. Do NOT retry. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Discord tool not properly configured.", @@ -50,7 +50,7 @@ def create_send_discord_message_tool( try: connector = await get_discord_connector( - db_session, search_space_id, user_id + db_session, workspace_id, user_id ) if not connector: return {"status": "error", "message": "No Discord connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py index 7732c35e5..c858fe532 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py @@ -58,7 +58,7 @@ def _markdown_to_docx(markdown_text: str) -> bytes: def create_create_dropbox_file_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -84,7 +84,7 @@ def create_create_dropbox_file_tool( f"create_dropbox_file called: name='{name}', file_type='{file_type}'" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Dropbox tool not properly configured.", @@ -93,7 +93,7 @@ def create_create_dropbox_file_tool( try: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.DROPBOX_CONNECTOR, @@ -195,7 +195,7 @@ def create_create_dropbox_file_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.DROPBOX_CONNECTOR, @@ -244,7 +244,7 @@ def create_create_dropbox_file_tool( web_url=web_url, content=final_content, connector_id=connector.id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) if kb_result["status"] == "success": diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/index.py index 440b4583c..757fe58bc 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/index.py @@ -25,7 +25,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py index c713bdd00..11ca73d0c 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) def create_delete_dropbox_file_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -57,7 +57,7 @@ def create_delete_dropbox_file_tool( f"delete_dropbox_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Dropbox tool not properly configured.", @@ -72,7 +72,7 @@ def create_delete_dropbox_file_tool( ) .filter( and_( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.document_type == DocumentType.DROPBOX_FILE, func.lower(Document.title) == func.lower(file_name), SearchSourceConnector.user_id == user_id, @@ -92,7 +92,7 @@ def create_delete_dropbox_file_tool( ) .filter( and_( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.document_type == DocumentType.DROPBOX_FILE, func.lower( cast( @@ -140,7 +140,7 @@ def create_delete_dropbox_file_tool( select(SearchSourceConnector).filter( and_( SearchSourceConnector.id == document.connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.DROPBOX_CONNECTOR, @@ -202,7 +202,7 @@ def create_delete_dropbox_file_tool( select(SearchSourceConnector).filter( and_( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.DROPBOX_CONNECTOR, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py index 3f25305c5..cd3f9a67e 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def create_create_gmail_draft_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -59,7 +59,7 @@ def create_create_gmail_draft_tool( """ logger.info(f"create_gmail_draft called: to='{to}', subject='{subject}'") - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Gmail tool not properly configured. Please contact support.", @@ -68,7 +68,7 @@ def create_create_gmail_draft_tool( try: metadata_service = GmailToolMetadataService(db_session) context = await metadata_service.get_creation_context( - search_space_id, user_id + workspace_id, user_id ) if "error" in context: @@ -127,7 +127,7 @@ def create_create_gmail_draft_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_gmail_types), ) @@ -142,7 +142,7 @@ def create_create_gmail_draft_tool( else: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_gmail_types), ) @@ -307,7 +307,7 @@ def create_create_gmail_draft_tool( date_str=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), body_text=final_body, connector_id=actual_connector_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, draft_id=created.get("id"), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/index.py index 60405dcf7..31459bdff 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/index.py @@ -29,7 +29,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py index 10c64c6c5..e1162edef 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py @@ -17,7 +17,7 @@ _GMAIL_TYPES = [ def create_read_gmail_email_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -32,13 +32,13 @@ def create_read_gmail_email_tool( Returns: Dictionary with status and the full email content formatted as markdown. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return {"status": "error", "message": "Gmail tool not properly configured."} try: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_GMAIL_TYPES), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py index 2c633d629..2ed04a26e 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py @@ -17,7 +17,7 @@ _GMAIL_TYPES = [ def create_search_gmail_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -38,7 +38,7 @@ def create_search_gmail_tool( Dictionary with status and a list of email summaries including message_id, subject, from, date, snippet. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return {"status": "error", "message": "Gmail tool not properly configured."} max_results = min(max_results, 20) @@ -46,7 +46,7 @@ def create_search_gmail_tool( try: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_GMAIL_TYPES), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py index 3431a2bc3..89af473b7 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) def create_send_gmail_email_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -86,7 +86,7 @@ def create_send_gmail_email_tool( tool_call_id=runtime.tool_call_id, ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: msg = "Gmail tool not properly configured. Please contact support." return _emit( {"status": "error", "message": msg}, @@ -97,7 +97,7 @@ def create_send_gmail_email_tool( try: metadata_service = GmailToolMetadataService(db_session) context = await metadata_service.get_creation_context( - search_space_id, user_id + workspace_id, user_id ) if "error" in context: @@ -168,7 +168,7 @@ def create_send_gmail_email_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_gmail_types), ) @@ -187,7 +187,7 @@ def create_send_gmail_email_tool( else: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_gmail_types), ) @@ -355,7 +355,7 @@ def create_send_gmail_email_tool( date_str=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), body_text=final_body, connector_id=actual_connector_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) if kb_result["status"] == "success": diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py index ef5882074..2302492a9 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) def create_trash_gmail_email_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -57,7 +57,7 @@ def create_trash_gmail_email_tool( f"trash_gmail_email called: email_subject_or_id='{email_subject_or_id}', delete_from_kb={delete_from_kb}" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Gmail tool not properly configured. Please contact support.", @@ -66,7 +66,7 @@ def create_trash_gmail_email_tool( try: metadata_service = GmailToolMetadataService(db_session) context = await metadata_service.get_trash_context( - search_space_id, user_id, email_subject_or_id + workspace_id, user_id, email_subject_or_id ) if "error" in context: @@ -144,7 +144,7 @@ def create_trash_gmail_email_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_gmail_types), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py index ef7839a1a..1db35883a 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def create_update_gmail_draft_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -78,7 +78,7 @@ def create_update_gmail_draft_tool( f"update_gmail_draft called: draft_subject_or_id='{draft_subject_or_id}'" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Gmail tool not properly configured. Please contact support.", @@ -87,7 +87,7 @@ def create_update_gmail_draft_tool( try: metadata_service = GmailToolMetadataService(db_session) context = await metadata_service.get_update_context( - search_space_id, user_id, draft_subject_or_id + workspace_id, user_id, draft_subject_or_id ) if "error" in context: @@ -174,7 +174,7 @@ def create_update_gmail_draft_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_gmail_types), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py index 9de4e0a4b..24a2d1685 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py @@ -22,7 +22,7 @@ _MIME_MAP: dict[str, str] = { def create_create_google_drive_file_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -67,7 +67,7 @@ def create_create_google_drive_file_tool( f"create_google_drive_file called: name='{name}', type='{file_type}'" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Google Drive tool not properly configured. Please contact support.", @@ -82,7 +82,7 @@ def create_create_google_drive_file_tool( try: metadata_service = GoogleDriveToolMetadataService(db_session) context = await metadata_service.get_creation_context( - search_space_id, user_id + workspace_id, user_id ) if "error" in context: @@ -149,7 +149,7 @@ def create_create_google_drive_file_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_drive_types), ) @@ -164,7 +164,7 @@ def create_create_google_drive_file_tool( else: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_drive_types), ) @@ -288,7 +288,7 @@ def create_create_google_drive_file_tool( web_view_link=created.get("webViewLink"), content=final_content, connector_id=actual_connector_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) if kb_result["status"] == "success": diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/index.py index caf06d6ba..33d9c8451 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/index.py @@ -25,7 +25,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py index c89b54c8e..5d25582ee 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) def create_delete_google_drive_file_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -57,7 +57,7 @@ def create_delete_google_drive_file_tool( f"delete_google_drive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "Google Drive tool not properly configured. Please contact support.", @@ -66,7 +66,7 @@ def create_delete_google_drive_file_tool( try: metadata_service = GoogleDriveToolMetadataService(db_session) context = await metadata_service.get_trash_context( - search_space_id, user_id, file_name + workspace_id, user_id, file_name ) if "error" in context: @@ -144,7 +144,7 @@ def create_delete_google_drive_file_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type.in_(_drive_types), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/_auth.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/_auth.py index c6d1cd148..9dd4acbef 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/_auth.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/_auth.py @@ -10,12 +10,12 @@ LUMA_API = "https://public-api.luma.com/v1" async def get_luma_connector( db_session: AsyncSession, - search_space_id: int, + workspace_id: int, user_id: str, ) -> SearchSourceConnector | None: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.LUMA_CONNECTOR, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/create_event.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/create_event.py index 0dffb2d2c..777cc7169 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/create_event.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/create_event.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) def create_create_luma_event_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -42,11 +42,11 @@ def create_create_luma_event_tool( IMPORTANT: - If status is "rejected", the user explicitly declined. Do NOT retry. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return {"status": "error", "message": "Luma tool not properly configured."} try: - connector = await get_luma_connector(db_session, search_space_id, user_id) + connector = await get_luma_connector(db_session, workspace_id, user_id) if not connector: return {"status": "error", "message": "No Luma connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/index.py index a479331bb..d6c4d05e7 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/index.py @@ -26,7 +26,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/list_events.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/list_events.py index aec5ad220..35f01bc63 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/list_events.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/list_events.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) def create_list_luma_events_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -28,13 +28,13 @@ def create_list_luma_events_tool( Dictionary with status and a list of events including event_id, name, start_at, end_at, location, url. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return {"status": "error", "message": "Luma tool not properly configured."} max_results = min(max_results, 50) try: - connector = await get_luma_connector(db_session, search_space_id, user_id) + connector = await get_luma_connector(db_session, workspace_id, user_id) if not connector: return {"status": "error", "message": "No Luma connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/read_event.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/read_event.py index b37a9d617..a9fe06657 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/read_event.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/luma/tools/read_event.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) def create_read_luma_event_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -26,11 +26,11 @@ def create_read_luma_event_tool( Dictionary with status and full event details including description, attendees count, meeting URL. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return {"status": "error", "message": "Luma tool not properly configured."} try: - connector = await get_luma_connector(db_session, search_space_id, user_id) + connector = await get_luma_connector(db_session, workspace_id, user_id) if not connector: return {"status": "error", "message": "No Luma connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/create_page.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/create_page.py index 49ee0f3aa..57f14a849 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/create_page.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/create_page.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) def create_create_notion_page_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, connector_id: int | None = None, ): @@ -24,7 +24,7 @@ def create_create_notion_page_tool( Args: db_session: Database session for accessing Notion connector - search_space_id: Search space ID to find the Notion connector + workspace_id: Workspace ID to find the Notion connector user_id: User ID for fetching user-specific context connector_id: Optional specific connector ID (if known) @@ -69,7 +69,7 @@ def create_create_notion_page_tool( """ logger.info(f"create_notion_page called: title='{title}'") - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: logger.error( "Notion tool not properly configured - missing required parameters" ) @@ -81,7 +81,7 @@ def create_create_notion_page_tool( try: metadata_service = NotionToolMetadataService(db_session) context = await metadata_service.get_creation_context( - search_space_id, user_id + workspace_id, user_id ) if "error" in context: @@ -144,7 +144,7 @@ def create_create_notion_page_tool( if actual_connector_id is None: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.NOTION_CONNECTOR, @@ -154,7 +154,7 @@ def create_create_notion_page_tool( if not connector: logger.warning( - f"No Notion connector found for search_space_id={search_space_id}" + f"No Notion connector found for workspace_id={workspace_id}" ) return { "status": "error", @@ -167,7 +167,7 @@ def create_create_notion_page_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == actual_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.NOTION_CONNECTOR, @@ -177,7 +177,7 @@ def create_create_notion_page_tool( if not connector: logger.error( - f"Invalid connector_id={actual_connector_id} for search_space_id={search_space_id}" + f"Invalid connector_id={actual_connector_id} for workspace_id={workspace_id}" ) return { "status": "error", @@ -211,7 +211,7 @@ def create_create_notion_page_tool( page_url=result.get("url"), content=final_content, connector_id=actual_connector_id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) if kb_result["status"] == "success": diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py index a187b2cbc..5c7a8f34c 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) def create_delete_notion_page_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, connector_id: int | None = None, ): @@ -28,7 +28,7 @@ def create_delete_notion_page_tool( Args: db_session: Database session for accessing Notion connector - search_space_id: Search space ID to find the Notion connector + workspace_id: Workspace ID to find the Notion connector user_id: User ID for finding the correct Notion connector connector_id: Optional specific connector ID (if known) @@ -91,7 +91,7 @@ def create_delete_notion_page_tool( tool_call_id=runtime.tool_call_id, ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: logger.error( "Notion tool not properly configured - missing required parameters" ) @@ -108,7 +108,7 @@ def create_delete_notion_page_tool( # Get page context (page_id, account, title) from indexed data metadata_service = NotionToolMetadataService(db_session) context = await metadata_service.get_delete_context( - search_space_id, user_id, page_title + workspace_id, user_id, page_title ) if "error" in context: @@ -193,7 +193,7 @@ def create_delete_notion_page_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.NOTION_CONNECTOR, @@ -203,7 +203,7 @@ def create_delete_notion_page_tool( if not connector: logger.error( - f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + f"Invalid connector_id={final_connector_id} for workspace_id={workspace_id}" ) return _emit( { diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/index.py index b8f662b03..4e8ed3c68 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/index.py @@ -26,7 +26,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/update_page.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/update_page.py index 6950f0abd..0fb529e89 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/update_page.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/notion/tools/update_page.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) def create_update_notion_page_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, connector_id: int | None = None, ): @@ -24,7 +24,7 @@ def create_update_notion_page_tool( Args: db_session: Database session for accessing Notion connector - search_space_id: Search space ID to find the Notion connector + workspace_id: Workspace ID to find the Notion connector user_id: User ID for fetching user-specific context connector_id: Optional specific connector ID (if known) @@ -73,7 +73,7 @@ def create_update_notion_page_tool( f"update_notion_page called: page_title='{page_title}', content_length={len(content) if content else 0}" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: logger.error( "Notion tool not properly configured - missing required parameters" ) @@ -92,7 +92,7 @@ def create_update_notion_page_tool( try: metadata_service = NotionToolMetadataService(db_session) context = await metadata_service.get_update_context( - search_space_id, user_id, page_title + workspace_id, user_id, page_title ) if "error" in context: @@ -165,7 +165,7 @@ def create_update_notion_page_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.NOTION_CONNECTOR, @@ -175,7 +175,7 @@ def create_update_notion_page_tool( if not connector: logger.error( - f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + f"Invalid connector_id={final_connector_id} for workspace_id={workspace_id}" ) return { "status": "error", @@ -212,7 +212,7 @@ def create_update_notion_page_tool( document_id=document_id, appended_content=final_content, user_id=user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, appended_block_ids=result.get("appended_block_ids"), ) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py index 11160650d..ac5ba2451 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py @@ -47,7 +47,7 @@ def _markdown_to_docx(markdown_text: str) -> bytes: def create_create_onedrive_file_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -72,7 +72,7 @@ def create_create_onedrive_file_tool( """ logger.info(f"create_onedrive_file called: name='{name}'") - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "OneDrive tool not properly configured.", @@ -81,7 +81,7 @@ def create_create_onedrive_file_tool( try: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, @@ -179,7 +179,7 @@ def create_create_onedrive_file_tool( result = await db_session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, @@ -221,7 +221,7 @@ def create_create_onedrive_file_tool( web_url=created.get("webUrl"), content=final_content, connector_id=connector.id, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, ) if kb_result["status"] == "success": diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/index.py index 4f0a2a7d6..2795bc14e 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/index.py @@ -25,7 +25,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py index 7b4e0b98c..be1b0493c 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) def create_delete_onedrive_file_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -58,7 +58,7 @@ def create_delete_onedrive_file_tool( f"delete_onedrive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}" ) - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return { "status": "error", "message": "OneDrive tool not properly configured.", @@ -73,7 +73,7 @@ def create_delete_onedrive_file_tool( ) .filter( and_( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.document_type == DocumentType.ONEDRIVE_FILE, func.lower(Document.title) == func.lower(file_name), SearchSourceConnector.user_id == user_id, @@ -93,7 +93,7 @@ def create_delete_onedrive_file_tool( ) .filter( and_( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.document_type == DocumentType.ONEDRIVE_FILE, func.lower( cast( @@ -140,7 +140,7 @@ def create_delete_onedrive_file_tool( select(SearchSourceConnector).filter( and_( SearchSourceConnector.id == document.connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, @@ -202,7 +202,7 @@ def create_delete_onedrive_file_tool( select(SearchSourceConnector).filter( and_( SearchSourceConnector.id == final_connector_id, - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/_auth.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/_auth.py index 7cdbeb819..e6600e828 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/_auth.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/_auth.py @@ -10,12 +10,12 @@ GRAPH_API = "https://graph.microsoft.com/v1.0" async def get_teams_connector( db_session: AsyncSession, - search_space_id: int, + workspace_id: int, user_id: str, ) -> SearchSourceConnector | None: result = await db_session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.user_id == user_id, SearchSourceConnector.connector_type == SearchSourceConnectorType.TEAMS_CONNECTOR, diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/index.py index d144eee82..ae4959d5b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/index.py @@ -26,7 +26,7 @@ def load_tools( d = {**(dependencies or {}), **kwargs} common = { "db_session": d["db_session"], - "search_space_id": d["search_space_id"], + "workspace_id": d["workspace_id"], "user_id": d["user_id"], } return [ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py index d7b000853..ca031b35b 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) def create_list_teams_channels_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -23,11 +23,11 @@ def create_list_teams_channels_tool( Dictionary with status and a list of teams, each containing team_id, team_name, and a list of channels (id, name). """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return {"status": "error", "message": "Teams tool not properly configured."} try: - connector = await get_teams_connector(db_session, search_space_id, user_id) + connector = await get_teams_connector(db_session, workspace_id, user_id) if not connector: return {"status": "error", "message": "No Teams connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py index d24a7e4d3..ff3b7c75a 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) def create_read_teams_messages_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -32,13 +32,13 @@ def create_read_teams_messages_tool( Dictionary with status and a list of messages including id, sender, content, timestamp. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return {"status": "error", "message": "Teams tool not properly configured."} limit = min(limit, 50) try: - connector = await get_teams_connector(db_session, search_space_id, user_id) + connector = await get_teams_connector(db_session, workspace_id, user_id) if not connector: return {"status": "error", "message": "No Teams connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/send_message.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/send_message.py index c4491e82e..47c0fb700 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/send_message.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/connectors/teams/tools/send_message.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) def create_send_teams_message_tool( db_session: AsyncSession | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, user_id: str | None = None, ): @tool @@ -41,11 +41,11 @@ def create_send_teams_message_tool( IMPORTANT: - If status is "rejected", the user explicitly declined. Do NOT retry. """ - if db_session is None or search_space_id is None or user_id is None: + if db_session is None or workspace_id is None or user_id is None: return {"status": "error", "message": "Teams tool not properly configured."} try: - connector = await get_teams_connector(db_session, search_space_id, user_id) + connector = await get_teams_connector(db_session, workspace_id, user_id) if not connector: return {"status": "error", "message": "No Teams connector found."} diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/mcp_tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/mcp_tools/index.py index 436b13aea..334f2130a 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/mcp_tools/index.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/mcp_tools/index.py @@ -29,12 +29,12 @@ logger = logging.getLogger(__name__) async def fetch_mcp_connector_metadata_maps( session: AsyncSession, - search_space_id: int, + workspace_id: int, ) -> tuple[dict[int, str], dict[str, str]]: """Resolve connector id and display name to connector type for MCP tool routing.""" result = await session.execute( select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, cast(SearchSourceConnector.config, JSONB).has_key("server_config"), ), ) @@ -101,13 +101,13 @@ def partition_mcp_tools_by_connector( async def load_mcp_tools_by_connector( session: AsyncSession, - search_space_id: int, + workspace_id: int, ) -> dict[str, list[BaseTool]]: """Load MCP tools and route them to each subagent as a flat list. ``bypass_internal_hitl=True`` is set so tool gating is uniformly the consuming subagent's :class:`PermissionMiddleware` responsibility. """ - flat = await load_mcp_tools(session, search_space_id, bypass_internal_hitl=True) - id_map, name_map = await fetch_mcp_connector_metadata_maps(session, search_space_id) + flat = await load_mcp_tools(session, workspace_id, bypass_internal_hitl=True) + id_map, name_map = await fetch_mcp_connector_metadata_maps(session, workspace_id) return partition_mcp_tools_by_connector(flat, id_map, name_map) diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/hitl/approvals/self_gated/auto_approved.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/hitl/approvals/self_gated/auto_approved.py index b99b26f3a..e8056eb27 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/hitl/approvals/self_gated/auto_approved.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/hitl/approvals/self_gated/auto_approved.py @@ -12,7 +12,7 @@ untouched. This keeps tool bodies (logging, metadata fetches, account fallbacks) symmetrical with the prompted path; the only behavior change is "no interrupt fires". -Per-search-space ``agent_permission_rules`` (when wired) take precedence and +Per-workspace ``agent_permission_rules`` (when wired) take precedence and can re-enable prompting for any of these. """ diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/spec.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/spec.py index 6c68b96db..b3aa57c05 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/spec.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/spec.py @@ -13,7 +13,7 @@ from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset # A context-hint provider receives the parent-agent ``runtime.state`` mapping # and the ``description`` the orchestrator wrote, and returns a short string # the runtime prepends to the subagent's first ``HumanMessage``. Used for -# things like "current search-space id is X" or "the user is in workspace Y" — +# things like "current workspace id is X" or "the user is in workspace Y" — # never for full corpora, since the prepended text consumes the subagent's # prompt budget on every invocation. Return ``None`` (or an empty string) to # skip the hint for this call. @@ -52,7 +52,7 @@ class SurfSenseSubagentSpec: invocation, immediately before the subagent runs. Its return value is prepended to the subagent's first ``HumanMessage`` so the subagent can see things it would otherwise have to discover - (active search space, KB root, current user timezone, etc.). + (active workspace, KB root, current user timezone, etc.). Kept out of the deepagents ``spec`` because that dict is forwarded verbatim to upstream code and only recognises its own typed keys. """ diff --git a/surfsense_backend/app/agents/chat/runtime/mention_resolver.py b/surfsense_backend/app/agents/chat/runtime/mention_resolver.py index 4f2f47b24..7996bacd6 100644 --- a/surfsense_backend/app/agents/chat/runtime/mention_resolver.py +++ b/surfsense_backend/app/agents/chat/runtime/mention_resolver.py @@ -89,7 +89,7 @@ def _folder_virtual_path(folder_id: int, folder_paths: dict[int, str]) -> str: """Return ``/documents/Folder/Sub/`` for a folder id. Falls back to the documents root when the folder is missing from - the index (deleted or in a different search space). Trailing slash + the index (deleted or in a different workspace). Trailing slash matches ``KnowledgeTreeMiddleware`` (``/documents/MyFolder/``) so the agent's ``ls`` can dispatch on it as a directory. """ @@ -100,7 +100,7 @@ def _folder_virtual_path(folder_id: int, folder_paths: dict[int, str]) -> str: async def resolve_mentions( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, mentioned_documents: list[MentionedDocumentInfo] | None, mentioned_document_ids: list[int] | None = None, mentioned_folder_ids: list[int] | None = None, @@ -151,13 +151,13 @@ async def resolve_mentions( if not doc_id_pool and not folder_id_pool: return ResolvedMentionSet() - index = await build_path_index(session, search_space_id) + index = await build_path_index(session, workspace_id) doc_rows: dict[int, Document] = {} if doc_id_pool: result = await session.execute( select(Document).where( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.id.in_(doc_id_pool), ) ) @@ -168,7 +168,7 @@ async def resolve_mentions( if folder_id_pool: result = await session.execute( select(Folder).where( - Folder.search_space_id == search_space_id, + Folder.workspace_id == workspace_id, Folder.id.in_(folder_id_pool), ) ) @@ -185,7 +185,7 @@ async def resolve_mentions( logger.debug( "mention_resolver: dropping doc id=%s (not found in space=%s)", doc_id, - search_space_id, + workspace_id, ) continue title = chip_titles_by_id.get(("doc", doc_id), str(row.title or "")) @@ -206,7 +206,7 @@ async def resolve_mentions( logger.debug( "mention_resolver: dropping folder id=%s (not found in space=%s)", folder_id, - search_space_id, + workspace_id, ) continue title = chip_titles_by_id.get(("folder", folder_id), str(row.name or "")) diff --git a/surfsense_backend/app/agents/chat/runtime/path_resolver.py b/surfsense_backend/app/agents/chat/runtime/path_resolver.py index 84282b63b..92a6b1934 100644 --- a/surfsense_backend/app/agents/chat/runtime/path_resolver.py +++ b/surfsense_backend/app/agents/chat/runtime/path_resolver.py @@ -98,12 +98,12 @@ class PathIndex: async def _build_folder_paths( session: AsyncSession, - search_space_id: int, + workspace_id: int, ) -> dict[int, str]: """Compute ``Folder.id`` -> absolute virtual path under ``/documents``.""" result = await session.execute( select(Folder.id, Folder.name, Folder.parent_id).where( - Folder.search_space_id == search_space_id + Folder.workspace_id == workspace_id ) ) rows = result.all() @@ -133,11 +133,11 @@ async def _build_folder_paths( async def build_path_index( session: AsyncSession, - search_space_id: int, + workspace_id: int, *, populate_occupants: bool = True, ) -> PathIndex: - """Build a :class:`PathIndex` for a search space. + """Build a :class:`PathIndex` for a workspace. ``populate_occupants`` controls whether the occupancy map is pre-seeded from existing ``Document`` rows. Most callers want this so that @@ -145,12 +145,12 @@ async def build_path_index( the persistence middleware sets this to ``False`` when it is iterating to decide where to place fresh documents. """ - folder_paths = await _build_folder_paths(session, search_space_id) + folder_paths = await _build_folder_paths(session, workspace_id) occupants: dict[str, int] = {} if populate_occupants: rows = await session.execute( select(Document.id, Document.title, Document.folder_id).where( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, ) ) for row in rows.all(): @@ -189,7 +189,7 @@ def doc_to_virtual_path( async def virtual_path_to_doc( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, virtual_path: str, ) -> Document | None: """Resolve a virtual path back to a ``Document`` row. @@ -198,7 +198,7 @@ async def virtual_path_to_doc( 1. ``Document.unique_identifier_hash`` lookup (fast path for paths created by SurfSense itself — every NOTE write goes through this hash). 2. If the basename carries a ``" ().xml"`` disambiguation suffix, - try a direct id lookup constrained to the search space. + try a direct id lookup constrained to the workspace. 3. Title-from-basename + folder-resolution lookup as a last resort. """ if not virtual_path or not virtual_path.startswith(DOCUMENTS_ROOT): @@ -207,11 +207,11 @@ async def virtual_path_to_doc( unique_hash = generate_unique_identifier_hash( DocumentType.NOTE, virtual_path, - search_space_id, + workspace_id, ) result = await session.execute( select(Document).where( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.unique_identifier_hash == unique_hash, ) ) @@ -232,7 +232,7 @@ async def virtual_path_to_doc( if suffix_doc_id is not None: result = await session.execute( select(Document).where( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.id == suffix_doc_id, ) ) @@ -241,7 +241,7 @@ async def virtual_path_to_doc( return document folder_id = await _resolve_folder_id( - session, search_space_id=search_space_id, folder_parts=folder_parts + session, workspace_id=workspace_id, folder_parts=folder_parts ) title_candidates: list[str] = [] raw_title = stem @@ -253,7 +253,7 @@ async def virtual_path_to_doc( if not candidate: continue query = select(Document).where( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.title == candidate, ) if folder_id is None: @@ -272,7 +272,7 @@ async def virtual_path_to_doc( # filename back here. Scan all documents in the resolved folder and match # by ``safe_filename(title)`` to recover the original document. folder_scan = select(Document).where( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, ) if folder_id is None: folder_scan = folder_scan.where(Document.folder_id.is_(None)) @@ -289,7 +289,7 @@ async def virtual_path_to_doc( async def _resolve_folder_id( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, folder_parts: list[str], ) -> int | None: """Look up the leaf folder id for a chain of folder names; return ``None`` if missing.""" @@ -299,7 +299,7 @@ async def _resolve_folder_id( for raw in folder_parts: name = safe_folder_segment(raw) query = select(Folder.id).where( - Folder.search_space_id == search_space_id, + Folder.workspace_id == workspace_id, Folder.name == name, ) if parent_id is None: diff --git a/surfsense_backend/app/agents/chat/runtime/referenced_chat_context/resolver.py b/surfsense_backend/app/agents/chat/runtime/referenced_chat_context/resolver.py index bd6c2e150..c1e4ef9eb 100644 --- a/surfsense_backend/app/agents/chat/runtime/referenced_chat_context/resolver.py +++ b/surfsense_backend/app/agents/chat/runtime/referenced_chat_context/resolver.py @@ -19,7 +19,7 @@ from app.db import ( NewChatMessage, NewChatMessageRole, NewChatThread, - SearchSpace, + Workspace, ) from app.tasks.chat.llm_history_normalizer import ( assistant_content_to_llm_text, @@ -35,8 +35,8 @@ def _accessible_thread_filter(user_uuid: UUID | None, *, include_legacy: bool): """Visibility predicate mirroring ``new_chat_routes.search_threads``. A thread is referenceable when the requester created it, it is shared - with the search space, or it is a legacy null-creator thread and the - requester owns the search space (``include_legacy``). Anything else is + with the workspace, or it is a legacy null-creator thread and the + requester owns the workspace (``include_legacy``). Anything else is dropped (fail-closed). """ conditions = [NewChatThread.visibility == ChatVisibility.SEARCH_SPACE] @@ -50,7 +50,7 @@ def _accessible_thread_filter(user_uuid: UUID | None, *, include_legacy: bool): async def resolve_referenced_chats( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, requesting_user_id: str | None, current_chat_id: int, mentioned_thread_ids: list[int] | None, @@ -82,19 +82,19 @@ async def resolve_referenced_chats( if not requested_ids: return [] - # Legacy null-creator threads are referenceable only by the search-space + # Legacy null-creator threads are referenceable only by the workspace # owner, matching ``search_threads`` (the source the picker reads from). include_legacy = False if user_uuid is not None: owner_id = await session.scalar( - select(SearchSpace.user_id).where(SearchSpace.id == search_space_id) + select(Workspace.user_id).where(Workspace.id == workspace_id) ) include_legacy = owner_id == user_uuid thread_rows = await session.execute( select(NewChatThread).where( NewChatThread.id.in_(requested_ids), - NewChatThread.search_space_id == search_space_id, + NewChatThread.workspace_id == workspace_id, _accessible_thread_filter(user_uuid, include_legacy=include_legacy), ) ) @@ -103,7 +103,7 @@ async def resolve_referenced_chats( "resolve_referenced_chats: requested=%s accessible=%s space=%s user=%s", requested_ids, sorted(threads_by_id.keys()), - search_space_id, + workspace_id, user_uuid, ) if not threads_by_id: @@ -119,7 +119,7 @@ async def resolve_referenced_chats( "resolve_referenced_chats: dropping thread id=%s " "(not accessible in space=%s)", thread_id, - search_space_id, + workspace_id, ) continue turns = turns_by_thread.get(thread_id, []) diff --git a/surfsense_backend/app/agents/chat/runtime/references/__init__.py b/surfsense_backend/app/agents/chat/runtime/references/__init__.py index 62530fd71..40e0249d3 100644 --- a/surfsense_backend/app/agents/chat/runtime/references/__init__.py +++ b/surfsense_backend/app/agents/chat/runtime/references/__init__.py @@ -29,7 +29,7 @@ from .reference_pointers import render_reference_pointers async def resolve_references( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, requesting_user_id: str | None, current_chat_id: int, document_ids: list[int] | None = None, @@ -46,18 +46,18 @@ async def resolve_references( references: list[Reference] = [] if document_ids or folder_ids: - index = await build_path_index(session, search_space_id) + index = await build_path_index(session, workspace_id) if document_ids: references += await resolve_document_references( session, - search_space_id=search_space_id, + workspace_id=workspace_id, document_ids=document_ids, index=index, ) if folder_ids: references += await resolve_folder_references( session, - search_space_id=search_space_id, + workspace_id=workspace_id, folder_ids=folder_ids, index=index, ) @@ -65,7 +65,7 @@ async def resolve_references( if connector_ids: references += await resolve_connector_references( session, - search_space_id=search_space_id, + workspace_id=workspace_id, connector_ids=connector_ids, chips=connector_chips, ) @@ -73,7 +73,7 @@ async def resolve_references( if thread_ids: references += await resolve_chat_references( session, - search_space_id=search_space_id, + workspace_id=workspace_id, requesting_user_id=requesting_user_id, current_chat_id=current_chat_id, thread_ids=thread_ids, diff --git a/surfsense_backend/app/agents/chat/runtime/references/chat/access.py b/surfsense_backend/app/agents/chat/runtime/references/chat/access.py index 1f7614b06..df18f99ff 100644 --- a/surfsense_backend/app/agents/chat/runtime/references/chat/access.py +++ b/surfsense_backend/app/agents/chat/runtime/references/chat/access.py @@ -1,8 +1,8 @@ """Access-checked lookup of chat threads the requester may read. The single place chat visibility is enforced: a thread is readable when it is -shared with the search space, the requester created it, or it is a legacy -null-creator thread and the requester owns the search space. Anything else is +shared with the workspace, the requester created it, or it is a legacy +null-creator thread and the requester owns the workspace. Anything else is dropped (fail-closed). """ @@ -14,7 +14,7 @@ from uuid import UUID from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.db import ChatVisibility, NewChatThread, SearchSpace +from app.db import ChatVisibility, NewChatThread, Workspace logger = logging.getLogger(__name__) @@ -32,7 +32,7 @@ def _visibility_predicate(user_uuid: UUID | None, *, include_legacy: bool): async def accessible_threads( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, requesting_user_id: str | None, thread_ids: list[int], exclude_thread_id: int | None = None, @@ -57,18 +57,18 @@ async def accessible_threads( requesting_user_id, ) - # Legacy null-creator threads are readable only by the search-space owner. + # Legacy null-creator threads are readable only by the workspace owner. include_legacy = False if user_uuid is not None: owner_id = await session.scalar( - select(SearchSpace.user_id).where(SearchSpace.id == search_space_id) + select(Workspace.user_id).where(Workspace.id == workspace_id) ) include_legacy = owner_id == user_uuid rows = await session.execute( select(NewChatThread).where( NewChatThread.id.in_(requested), - NewChatThread.search_space_id == search_space_id, + NewChatThread.workspace_id == workspace_id, _visibility_predicate(user_uuid, include_legacy=include_legacy), ) ) diff --git a/surfsense_backend/app/agents/chat/runtime/references/chat/resolver.py b/surfsense_backend/app/agents/chat/runtime/references/chat/resolver.py index 4e267dff3..3f1566d99 100644 --- a/surfsense_backend/app/agents/chat/runtime/references/chat/resolver.py +++ b/surfsense_backend/app/agents/chat/runtime/references/chat/resolver.py @@ -16,7 +16,7 @@ from .access import accessible_threads async def resolve_chat_references( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, requesting_user_id: str | None, current_chat_id: int, thread_ids: list[int], @@ -27,7 +27,7 @@ async def resolve_chat_references( threads = await accessible_threads( session, - search_space_id=search_space_id, + workspace_id=workspace_id, requesting_user_id=requesting_user_id, thread_ids=thread_ids, exclude_thread_id=current_chat_id, diff --git a/surfsense_backend/app/agents/chat/runtime/references/connectors.py b/surfsense_backend/app/agents/chat/runtime/references/connectors.py index ae2df15c3..78f74c2eb 100644 --- a/surfsense_backend/app/agents/chat/runtime/references/connectors.py +++ b/surfsense_backend/app/agents/chat/runtime/references/connectors.py @@ -29,13 +29,13 @@ def connector_pointer_fields( async def resolve_connector_references( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, connector_ids: list[int], chips: list[MentionedDocumentInfo] | None = None, ) -> list[ConnectorReference]: """Map ``@connector`` ids to references; ids outside the space are dropped. - The DB check only confirms the connector belongs to this search space; + The DB check only confirms the connector belongs to this workspace; display fields come from the user's chip. """ if not connector_ids: @@ -47,7 +47,7 @@ async def resolve_connector_references( SearchSourceConnector.name, SearchSourceConnector.connector_type, ).where( - SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.workspace_id == workspace_id, SearchSourceConnector.id.in_(connector_ids), ) ) diff --git a/surfsense_backend/app/agents/chat/runtime/references/documents/referenced.py b/surfsense_backend/app/agents/chat/runtime/references/documents/referenced.py index 4e05fd324..1e0745240 100644 --- a/surfsense_backend/app/agents/chat/runtime/references/documents/referenced.py +++ b/surfsense_backend/app/agents/chat/runtime/references/documents/referenced.py @@ -3,7 +3,7 @@ Reference resolution, not retrieval: this answers "which knowledge-base documents did the user point at this turn?". ``@document`` ids pass through; ``@folder`` ids expand to the documents directly inside each folder within this -search space (direct children only, not nested subfolders). The caller turns the +workspace (direct children only, not nested subfolders). The caller turns the returned ids into a retrieval ``SearchScope``. """ @@ -18,7 +18,7 @@ from app.db import Document async def referenced_document_ids( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, document_ids: list[int] | None = None, folder_ids: list[int] | None = None, ) -> tuple[int, ...]: @@ -28,7 +28,7 @@ async def referenced_document_ids( if folders: rows = await session.execute( select(Document.id).where( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.folder_id.in_(folders), ) ) diff --git a/surfsense_backend/app/agents/chat/runtime/references/documents/resolver.py b/surfsense_backend/app/agents/chat/runtime/references/documents/resolver.py index 72a459eb9..b39a365e4 100644 --- a/surfsense_backend/app/agents/chat/runtime/references/documents/resolver.py +++ b/surfsense_backend/app/agents/chat/runtime/references/documents/resolver.py @@ -14,13 +14,13 @@ from ..models import DocumentReference async def resolve_document_references( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, document_ids: list[int], index: PathIndex, ) -> list[DocumentReference]: """Map document ids to references in input order; unknown ids are dropped. - Best-effort and fail-closed: an id outside ``search_space_id`` (deleted or + Best-effort and fail-closed: an id outside ``workspace_id`` (deleted or foreign) simply does not produce a reference. """ if not document_ids: @@ -28,7 +28,7 @@ async def resolve_document_references( rows = await session.execute( select(Document).where( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.id.in_(document_ids), ) ) diff --git a/surfsense_backend/app/agents/chat/runtime/references/folders.py b/surfsense_backend/app/agents/chat/runtime/references/folders.py index df0ec457b..57b3a5bfb 100644 --- a/surfsense_backend/app/agents/chat/runtime/references/folders.py +++ b/surfsense_backend/app/agents/chat/runtime/references/folders.py @@ -20,7 +20,7 @@ def folder_pointer_path(folder_id: int, folder_paths: dict[int, str]) -> str: async def resolve_folder_references( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, folder_ids: list[int], index: PathIndex, ) -> list[FolderReference]: @@ -30,7 +30,7 @@ async def resolve_folder_references( rows = await session.execute( select(Folder).where( - Folder.search_space_id == search_space_id, + Folder.workspace_id == workspace_id, Folder.id.in_(folder_ids), ) ) diff --git a/surfsense_backend/app/agents/chat/shared/context.py b/surfsense_backend/app/agents/chat/shared/context.py index b543eb6b6..74cae70f5 100644 --- a/surfsense_backend/app/agents/chat/shared/context.py +++ b/surfsense_backend/app/agents/chat/shared/context.py @@ -41,7 +41,7 @@ class SurfSenseContextSchema: are optional; consumers must None-check before reading. Phase 1.5 fields: - search_space_id: Search space the request is scoped to. + workspace_id: Workspace the request is scoped to. mentioned_document_ids: KB documents the user @-mentioned this turn. Read by the ``search_knowledge_base`` tool to pin these docs into the retrieval scope. Stays out of the compiled-agent cache @@ -60,7 +60,7 @@ class SurfSenseContextSchema: by middleware ``__init__`` closures). """ - search_space_id: int | None = None + workspace_id: int | None = None mentioned_document_ids: list[int] = field(default_factory=list) mentioned_folder_ids: list[int] = field(default_factory=list) mentioned_connector_ids: list[int] = field(default_factory=list) diff --git a/surfsense_backend/app/agents/chat/shared/tools/web_search.py b/surfsense_backend/app/agents/chat/shared/tools/web_search.py index 424225b30..78f3c6ae2 100644 --- a/surfsense_backend/app/agents/chat/shared/tools/web_search.py +++ b/surfsense_backend/app/agents/chat/shared/tools/web_search.py @@ -115,7 +115,7 @@ def _to_renderable_web_documents( async def _search_live_connector( connector: str, query: str, - search_space_id: int, + workspace_id: int, top_k: int, semaphore: asyncio.Semaphore, ) -> list[dict[str, Any]]: @@ -128,7 +128,7 @@ async def _search_live_connector( method_name, _includes_date_range, includes_top_k, extra_kwargs = spec kwargs: dict[str, Any] = { "user_query": query, - "search_space_id": search_space_id, + "workspace_id": workspace_id, **extra_kwargs, } if includes_top_k: @@ -137,7 +137,7 @@ async def _search_live_connector( try: t0 = time.perf_counter() async with semaphore, shielded_async_session() as session: - svc = ConnectorService(session, search_space_id) + svc = ConnectorService(session, workspace_id) _, chunks = await getattr(svc, method_name)(**kwargs) perf.info( "[web_search] connector=%s results=%d in %.3fs", @@ -152,7 +152,7 @@ async def _search_live_connector( def create_web_search_tool( - search_space_id: int | None = None, + workspace_id: int | None = None, available_connectors: list[str] | None = None, ) -> BaseTool: """Factory for the ``web_search`` tool. @@ -178,7 +178,7 @@ def create_web_search_tool( "All configured engines are queried in parallel and results are merged." ) - _search_space_id = search_space_id + _workspace_id = workspace_id _active_live = active_live_connectors async def _web_search_impl( @@ -213,14 +213,14 @@ def create_web_search_tool( tasks.append(asyncio.ensure_future(_searxng())) - if _search_space_id is not None: + if _workspace_id is not None: for connector in _active_live: tasks.append( asyncio.ensure_future( _search_live_connector( connector=connector, query=query, - search_space_id=_search_space_id, + workspace_id=_workspace_id, top_k=clamped_top_k, semaphore=semaphore, ) diff --git a/surfsense_backend/app/agents/video_presentation/configuration.py b/surfsense_backend/app/agents/video_presentation/configuration.py index 18724a2ab..260e3f481 100644 --- a/surfsense_backend/app/agents/video_presentation/configuration.py +++ b/surfsense_backend/app/agents/video_presentation/configuration.py @@ -12,7 +12,7 @@ class Configuration: """The configuration for the video presentation agent.""" video_title: str - search_space_id: int + workspace_id: int user_prompt: str | None = None @classmethod diff --git a/surfsense_backend/app/agents/video_presentation/nodes.py b/surfsense_backend/app/agents/video_presentation/nodes.py index bdc0f80f8..dca89059f 100644 --- a/surfsense_backend/app/agents/video_presentation/nodes.py +++ b/surfsense_backend/app/agents/video_presentation/nodes.py @@ -49,12 +49,12 @@ async def create_presentation_slides( """Parse source content into structured presentation slides using LLM.""" configuration = Configuration.from_runnable_config(config) - search_space_id = configuration.search_space_id + workspace_id = configuration.workspace_id user_prompt = configuration.user_prompt - llm = await get_agent_llm(state.db_session, search_space_id) + llm = await get_agent_llm(state.db_session, workspace_id) if not llm: - error_message = f"No LLM configured for search space {search_space_id}" + error_message = f"No LLM configured for workspace {workspace_id}" print(error_message) raise RuntimeError(error_message) @@ -351,11 +351,11 @@ async def assign_slide_themes(state: State, config: RunnableConfig) -> dict[str, Runs in parallel with audio generation since it only needs slide metadata. """ configuration = Configuration.from_runnable_config(config) - search_space_id = configuration.search_space_id + workspace_id = configuration.workspace_id - llm = await get_agent_llm(state.db_session, search_space_id) + llm = await get_agent_llm(state.db_session, workspace_id) if not llm: - raise RuntimeError(f"No LLM configured for search space {search_space_id}") + raise RuntimeError(f"No LLM configured for workspace {workspace_id}") slides = state.slides or [] assignments = await _assign_themes_with_llm(llm, slides) @@ -372,11 +372,11 @@ async def generate_slide_scene_codes( """ configuration = Configuration.from_runnable_config(config) - search_space_id = configuration.search_space_id + workspace_id = configuration.workspace_id - llm = await get_agent_llm(state.db_session, search_space_id) + llm = await get_agent_llm(state.db_session, workspace_id) if not llm: - raise RuntimeError(f"No LLM configured for search space {search_space_id}") + raise RuntimeError(f"No LLM configured for workspace {workspace_id}") slides = state.slides or [] audio_results = state.slide_audio_results or [] diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 1c81c8c29..179101085 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -465,7 +465,7 @@ async def _warm_agent_jit_caches() -> None: Doing one throwaway compile during ``lifespan`` startup pre-pays that cost so the *first real request* doesn't. We do NOT prime :mod:`agent_cache` because the cache key requires real - ``thread_id`` / ``user_id`` / ``search_space_id`` / etc. — the + ``thread_id`` / ``user_id`` / ``workspace_id`` / etc. — the throwaway agent is genuinely thrown away and immediately collected. Safety diff --git a/surfsense_backend/app/automations/actions/builtin/agent_task/dependencies.py b/surfsense_backend/app/automations/actions/builtin/agent_task/dependencies.py index c9584ae2a..fecf180c2 100644 --- a/surfsense_backend/app/automations/actions/builtin/agent_task/dependencies.py +++ b/surfsense_backend/app/automations/actions/builtin/agent_task/dependencies.py @@ -13,7 +13,7 @@ from app.automations.services.model_policy import ( assert_automation_models_billable, assert_models_billable, ) -from app.db import SearchSpace +from app.db import Workspace from app.tasks.chat.streaming.flows.shared.llm_bundle import load_llm_bundle from app.tasks.chat.streaming.flows.shared.pre_stream_setup import ( setup_connector_and_firecrawl, @@ -38,7 +38,7 @@ class AgentDependencies: async def build_dependencies( *, session: AsyncSession, - search_space_id: int, + workspace_id: int, chat_model_id: int | None = None, image_gen_model_id: int | None = None, vision_model_id: int | None = None, @@ -46,13 +46,13 @@ async def build_dependencies( """Load the LLM bundle, connector service, and a per-invoke in-memory checkpointer. Resolves the chat model from the automation's *captured* model snapshot - (``chat_model_id``) so runs are insulated from later chat/search-space model + (``chat_model_id``) so runs are insulated from later chat/workspace model changes. The model policy is enforced here as a runtime backstop: a captured model that is no longer billable (e.g. a premium global config was removed) fails the run clearly instead of silently consuming a free model. When ``chat_model_id`` is ``None`` (no captured snapshot — defensive fallback), - fall back to the live search space's ``chat_model_id`` and validate that. + fall back to the live workspace's ``chat_model_id`` and validate that. """ if chat_model_id is not None: try: @@ -65,25 +65,25 @@ async def build_dependencies( raise DependencyError(str(exc)) from exc resolved_chat_model_id = chat_model_id or 0 else: - search_space = await session.get(SearchSpace, search_space_id) - if search_space is None: - raise DependencyError(f"search space {search_space_id} not found") + workspace = await session.get(Workspace, workspace_id) + if workspace is None: + raise DependencyError(f"workspace {workspace_id} not found") try: - assert_automation_models_billable(search_space) + assert_automation_models_billable(workspace) except AutomationModelPolicyError as exc: raise DependencyError(str(exc)) from exc - resolved_chat_model_id = search_space.chat_model_id or 0 + resolved_chat_model_id = workspace.chat_model_id or 0 llm, agent_config, err = await load_llm_bundle( session, config_id=resolved_chat_model_id, - search_space_id=search_space_id, + workspace_id=workspace_id, ) if err is not None or llm is None: raise DependencyError(err or "failed to load chat model config") connector_service, firecrawl_api_key = await setup_connector_and_firecrawl( - session, search_space_id=search_space_id + session, workspace_id=workspace_id ) # Per-task InMemorySaver: the shared Postgres checkpointer's connection # pool binds connections to the loop that opened them, but Celery uses a diff --git a/surfsense_backend/app/automations/actions/builtin/agent_task/invoke.py b/surfsense_backend/app/automations/actions/builtin/agent_task/invoke.py index e1ba32ce9..91ebd962e 100644 --- a/surfsense_backend/app/automations/actions/builtin/agent_task/invoke.py +++ b/surfsense_backend/app/automations/actions/builtin/agent_task/invoke.py @@ -65,7 +65,7 @@ def _build_connector_block(connectors: list[dict[str, Any]]) -> str | None: async def _resolve_mention_context( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, query: str, mentioned_document_ids: list[int] | None, mentioned_folder_ids: list[int] | None, @@ -94,7 +94,7 @@ async def _resolve_mention_context( resolved = await resolve_mentions( session, - search_space_id=search_space_id, + workspace_id=workspace_id, mentioned_documents=mentioned_documents, mentioned_document_ids=mentioned_document_ids, mentioned_folder_ids=mentioned_folder_ids, @@ -109,7 +109,7 @@ async def _resolve_mention_context( agent_query = f"{connector_block}\n\n{agent_query}" runtime_context = SurfSenseContextSchema( - search_space_id=search_space_id, + workspace_id=workspace_id, mentioned_document_ids=list( resolved.mentioned_document_ids or (mentioned_document_ids or []) ), @@ -156,7 +156,7 @@ async def run_agent_task( deps = await build_dependencies( session=agent_session, - search_space_id=ctx.search_space_id, + workspace_id=ctx.workspace_id, chat_model_id=ctx.chat_model_id, image_gen_model_id=ctx.image_gen_model_id, vision_model_id=ctx.vision_model_id, @@ -164,7 +164,7 @@ async def run_agent_task( agent = await create_multi_agent_chat_deep_agent( llm=deps.llm, - search_space_id=ctx.search_space_id, + workspace_id=ctx.workspace_id, db_session=agent_session, connector_service=deps.connector_service, checkpointer=deps.checkpointer, @@ -180,7 +180,7 @@ async def run_agent_task( agent_query, runtime_context = await _resolve_mention_context( agent_session, - search_space_id=ctx.search_space_id, + workspace_id=ctx.workspace_id, query=query, mentioned_document_ids=mentioned_document_ids, mentioned_folder_ids=mentioned_folder_ids, @@ -193,7 +193,7 @@ async def run_agent_task( turn_id = f"{request_id}:{int(time.time() * 1000)}" input_state: dict[str, Any] = { "messages": [HumanMessage(content=agent_query)], - "search_space_id": ctx.search_space_id, + "workspace_id": ctx.workspace_id, "request_id": request_id, "turn_id": turn_id, } diff --git a/surfsense_backend/app/automations/actions/types.py b/surfsense_backend/app/automations/actions/types.py index 3ee427512..0273559f3 100644 --- a/surfsense_backend/app/automations/actions/types.py +++ b/surfsense_backend/app/automations/actions/types.py @@ -18,11 +18,11 @@ class ActionContext: session: AsyncSession run_id: int step_id: str - search_space_id: int + workspace_id: int creator_user_id: UUID | None # Captured model snapshot from the automation definition (``definition.models``), - # resolved per run instead of the live search space. ``None`` falls back to the - # search space's current prefs (defensive; should not happen post-capture). + # resolved per run instead of the live workspace. ``None`` falls back to the + # workspace's current prefs (defensive; should not happen post-capture). chat_model_id: int | None = None image_gen_model_id: int | None = None vision_model_id: int | None = None diff --git a/surfsense_backend/app/automations/api/automation.py b/surfsense_backend/app/automations/api/automation.py index 911ae57a6..59f05a7bb 100644 --- a/surfsense_backend/app/automations/api/automation.py +++ b/surfsense_backend/app/automations/api/automation.py @@ -44,14 +44,14 @@ async def create_automation( @router.get("/automations", response_model=AutomationList) async def list_automations( - search_space_id: int = Query(...), + workspace_id: int = Query(...), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), service: AutomationService = Depends(get_automation_service), ) -> AutomationList: - """List automations in a search space.""" + """List automations in a workspace.""" items, total = await service.list( - search_space_id=search_space_id, limit=limit, offset=offset + workspace_id=workspace_id, limit=limit, offset=offset ) return AutomationList( items=[AutomationSummary.model_validate(a) for a in items], @@ -61,10 +61,10 @@ async def list_automations( @router.get("/automations/model-eligibility", response_model=ModelEligibility) async def get_automation_model_eligibility( - search_space_id: int = Query(...), + workspace_id: int = Query(...), service: AutomationService = Depends(get_automation_service), ) -> ModelEligibility: - """Report whether a search space's models are billable for automations. + """Report whether a workspace's models are billable for automations. Used by the frontend to gate creation: automations may only use premium global models or user BYOK models (free models and Auto mode are blocked). @@ -72,7 +72,7 @@ async def get_automation_model_eligibility( NOTE: declared before ``/automations/{automation_id}`` so the literal path isn't captured by the int-typed ``{automation_id}`` route. """ - result = await service.model_eligibility(search_space_id=search_space_id) + result = await service.model_eligibility(workspace_id=workspace_id) return ModelEligibility.model_validate(result) diff --git a/surfsense_backend/app/automations/persistence/models/automation.py b/surfsense_backend/app/automations/persistence/models/automation.py index cb0b2ed31..486a1ecea 100644 --- a/surfsense_backend/app/automations/persistence/models/automation.py +++ b/surfsense_backend/app/automations/persistence/models/automation.py @@ -24,9 +24,9 @@ from ..enums.automation_status import AutomationStatus class Automation(BaseModel, TimestampMixin): __tablename__ = "automations" - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -65,7 +65,7 @@ class Automation(BaseModel, TimestampMixin): index=True, ) - search_space = relationship("SearchSpace", back_populates="automations") + workspace = relationship("Workspace", back_populates="automations") created_by = relationship("User", back_populates="automations") triggers = relationship( "AutomationTrigger", diff --git a/surfsense_backend/app/automations/runtime/executor.py b/surfsense_backend/app/automations/runtime/executor.py index bcdab3940..d9544cd0c 100644 --- a/surfsense_backend/app/automations/runtime/executor.py +++ b/surfsense_backend/app/automations/runtime/executor.py @@ -108,7 +108,7 @@ def _build_template_ctx( automation_id=run.automation_id, automation_name=automation.name if automation else None, automation_version=automation.version if automation else None, - search_space_id=automation.search_space_id if automation else None, + workspace_id=automation.workspace_id if automation else None, creator_id=automation.created_by_user_id if automation else None, trigger_id=run.trigger_id, trigger_type=trigger.type.value if trigger else None, @@ -130,7 +130,7 @@ def _build_action_ctx( session=session, run_id=run.id, step_id=step.step_id, - search_space_id=automation.search_space_id, + workspace_id=automation.workspace_id, creator_user_id=automation.created_by_user_id, chat_model_id=models.chat_model_id if models else None, image_gen_model_id=models.image_gen_model_id if models else None, diff --git a/surfsense_backend/app/automations/schemas/api/automation.py b/surfsense_backend/app/automations/schemas/api/automation.py index c1defd417..d3dbce1e0 100644 --- a/surfsense_backend/app/automations/schemas/api/automation.py +++ b/surfsense_backend/app/automations/schemas/api/automation.py @@ -17,7 +17,7 @@ class AutomationCreate(BaseModel): model_config = ConfigDict(extra="forbid") - search_space_id: int + workspace_id: int name: str = Field(..., min_length=1, max_length=200) description: str | None = None definition: AutomationDefinition @@ -41,7 +41,7 @@ class AutomationSummary(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - search_space_id: int + workspace_id: int name: str description: str | None = None status: AutomationStatus diff --git a/surfsense_backend/app/automations/schemas/definition/envelope.py b/surfsense_backend/app/automations/schemas/definition/envelope.py index 787534d4a..0a59ff168 100644 --- a/surfsense_backend/app/automations/schemas/definition/envelope.py +++ b/surfsense_backend/app/automations/schemas/definition/envelope.py @@ -14,8 +14,8 @@ from .trigger_spec import TriggerSpec class AutomationModels(BaseModel): """Captured model profile for an automation. - Snapshotted from the search space's model roles at create time so runs are - insulated from later chat/search-space model changes. Model-id conventions + Snapshotted from the workspace's model roles at create time so runs are + insulated from later chat/workspace model changes. Model-id conventions match the shared scheme (``0`` Auto, ``< 0`` global, ``> 0`` BYOK). """ @@ -40,6 +40,6 @@ class AutomationDefinition(BaseModel): execution: Execution = Field(default_factory=Execution) metadata: Metadata = Field(default_factory=Metadata) # Captured server-side at create() and preserved across update(); resolved - # at runtime instead of the live search space. Optional so drafts/builder + # at runtime instead of the live workspace. Optional so drafts/builder # payloads validate without it. models: AutomationModels | None = None diff --git a/surfsense_backend/app/automations/services/automation.py b/surfsense_backend/app/automations/services/automation.py index ed748fb7c..eeb5b61bd 100644 --- a/surfsense_backend/app/automations/services/automation.py +++ b/surfsense_backend/app/automations/services/automation.py @@ -28,7 +28,7 @@ from app.automations.services.model_policy import ( ) from app.automations.triggers import get_trigger from app.automations.triggers.builtin.schedule import compute_next_fire_at -from app.db import Permission, SearchSpace, get_async_session +from app.db import Permission, Workspace, get_async_session from app.users import get_auth_context from app.utils.rbac import check_permission @@ -44,28 +44,28 @@ class AutomationService: async def create(self, payload: AutomationCreate) -> Automation: """Create an automation and its initial triggers in one transaction.""" await self._authorize( - payload.search_space_id, Permission.AUTOMATIONS_CREATE.value + payload.workspace_id, Permission.AUTOMATIONS_CREATE.value ) # Capture the model profile onto the definition so runs are insulated - # from later chat/search-space model changes. Two sources: + # from later chat/workspace model changes. Two sources: # 1. Explicit per-automation selection in ``payload.definition.models`` # (manual builder + chat approval card). Validate the chosen ids. - # 2. Fallback (no selection): snapshot the search space's current prefs. + # 2. Fallback (no selection): snapshot the workspace's current prefs. # Either way the captured ids are guaranteed billable (premium/BYOK). selected_models = payload.definition.models if selected_models is not None: self._assert_selected_models_billable(selected_models) else: - search_space = await self._assert_models_billable(payload.search_space_id) + workspace = await self._assert_models_billable(payload.workspace_id) payload.definition.models = AutomationModels( - chat_model_id=search_space.chat_model_id or 0, - image_gen_model_id=search_space.image_gen_model_id or 0, - vision_model_id=search_space.vision_model_id or 0, + chat_model_id=workspace.chat_model_id or 0, + image_gen_model_id=workspace.image_gen_model_id or 0, + vision_model_id=workspace.vision_model_id or 0, ) automation = Automation( - search_space_id=payload.search_space_id, + workspace_id=payload.workspace_id, created_by_user_id=self.user.id, name=payload.name, description=payload.description, @@ -82,14 +82,14 @@ class AutomationService: async def list( self, *, - search_space_id: int, + workspace_id: int, limit: int, offset: int, ) -> tuple[list[Automation], int]: """Return a page of automations and the total count.""" - await self._authorize(search_space_id, Permission.AUTOMATIONS_READ.value) + await self._authorize(workspace_id, Permission.AUTOMATIONS_READ.value) - base = select(Automation).where(Automation.search_space_id == search_space_id) + base = select(Automation).where(Automation.workspace_id == workspace_id) total = await self.session.scalar( select(func.count()).select_from(base.subquery()) ) @@ -111,7 +111,7 @@ class AutomationService: """Get an automation with its triggers loaded.""" automation = await self._get_with_triggers_or_raise(automation_id) await self._authorize( - automation.search_space_id, Permission.AUTOMATIONS_READ.value + automation.workspace_id, Permission.AUTOMATIONS_READ.value ) return automation @@ -119,7 +119,7 @@ class AutomationService: """Patch fields. Bumps ``version`` when ``definition`` changes.""" automation = await self._get_with_triggers_or_raise(automation_id) await self._authorize( - automation.search_space_id, Permission.AUTOMATIONS_UPDATE.value + automation.workspace_id, Permission.AUTOMATIONS_UPDATE.value ) data = patch.model_dump(exclude_unset=True) @@ -135,7 +135,7 @@ class AutomationService: # Model snapshot handling on edit: # * absent in the patch -> preserve the captured snapshot # (a non-model definition change never silently re-binds the - # automation to the current chat/search-space selection). + # automation to the current chat/workspace selection). # * unchanged from the snapshot -> keep as-is, no re-validation # (so editing an automation whose captured model later drifted # out of premium isn't blocked by an unrelated name/schedule edit). @@ -158,7 +158,7 @@ class AutomationService: """Delete an automation; FK cascades remove triggers and runs.""" automation = await self._get_or_raise(automation_id) await self._authorize( - automation.search_space_id, Permission.AUTOMATIONS_DELETE.value + automation.workspace_id, Permission.AUTOMATIONS_DELETE.value ) await self.session.delete(automation) await self.session.commit() @@ -184,46 +184,46 @@ class AutomationService: ) return automation - async def model_eligibility(self, *, search_space_id: int) -> dict: - """Return whether a search space's models are billable for automations. + async def model_eligibility(self, *, workspace_id: int) -> dict: + """Return whether a workspace's models are billable for automations. ``{"allowed": bool, "violations": [{kind, config_id, reason}, ...]}``. """ - await self._authorize(search_space_id, Permission.AUTOMATIONS_READ.value) - search_space = await self.session.get(SearchSpace, search_space_id) - if search_space is None: + await self._authorize(workspace_id, Permission.AUTOMATIONS_READ.value) + workspace = await self.session.get(Workspace, workspace_id) + if workspace is None: raise HTTPException( - status_code=404, detail=f"search space {search_space_id} not found" + status_code=404, detail=f"workspace {workspace_id} not found" ) - return get_automation_model_eligibility(search_space) + return get_automation_model_eligibility(workspace) - async def _assert_models_billable(self, search_space_id: int) -> SearchSpace: - """Reject creation when the search space's models aren't billable. + async def _assert_models_billable(self, workspace_id: int) -> Workspace: + """Reject creation when the workspace's models aren't billable. Automations may only use premium global models or user BYOK models; free global models and Auto mode are blocked. Mirrors the runtime backstop in ``agent_task`` so users can't save an automation that would fail to run. - Returns the loaded :class:`SearchSpace` so the caller can capture its + Returns the loaded :class:`Workspace` so the caller can capture its model prefs without a second DB read. """ - search_space = await self.session.get(SearchSpace, search_space_id) - if search_space is None: + workspace = await self.session.get(Workspace, workspace_id) + if workspace is None: raise HTTPException( - status_code=404, detail=f"search space {search_space_id} not found" + status_code=404, detail=f"workspace {workspace_id} not found" ) try: - assert_automation_models_billable(search_space) + assert_automation_models_billable(workspace) except AutomationModelPolicyError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - return search_space + return workspace def _assert_selected_models_billable(self, models: AutomationModels) -> None: """Reject creation when an explicitly selected model isn't billable. Used when the client supplies ``definition.models`` (per-automation selection from the builder or chat approval card). Same policy as the - search-space path: premium global or BYOK only, no free/Auto. + workspace path: premium global or BYOK only, no free/Auto. """ try: assert_models_billable( @@ -234,13 +234,13 @@ class AutomationService: except AutomationModelPolicyError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - async def _authorize(self, search_space_id: int, permission: str) -> None: + async def _authorize(self, workspace_id: int, permission: str) -> None: await check_permission( self.session, self.auth, - search_space_id, + workspace_id, permission, - f"You don't have permission to {permission.split(':')[1]} automations in this search space", + f"You don't have permission to {permission.split(':')[1]} automations in this workspace", ) diff --git a/surfsense_backend/app/automations/services/model_policy.py b/surfsense_backend/app/automations/services/model_policy.py index e18264246..e767e55d1 100644 --- a/surfsense_backend/app/automations/services/model_policy.py +++ b/surfsense_backend/app/automations/services/model_policy.py @@ -22,7 +22,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: - from app.db import SearchSpace + from app.db import Workspace ModelKind = Literal["chat", "image", "vision"] @@ -58,7 +58,7 @@ def _classify(kind: ModelKind, model_id: int | None) -> tuple[bool, str]: ) if model_id > 0: - # Positive id -> user/search-space BYOK model. Always allowed. + # Positive id -> user/workspace BYOK model. Always allowed. return True, "" # Negative id -> global model. Allowed only if premium. @@ -80,7 +80,7 @@ def get_model_eligibility( ) -> dict: """Return ``{"allowed": bool, "violations": [...]}`` for explicit model ids. - The ID-based core shared by both the search-space path (creation/eligibility) + The ID-based core shared by both the workspace path (creation/eligibility) and the captured-snapshot path (runtime backstop). Each violation is ``{"kind", "model_id", "reason"}``. """ @@ -99,21 +99,21 @@ def get_model_eligibility( return {"allowed": not violations, "violations": violations} -def get_automation_model_eligibility(search_space: SearchSpace) -> dict: - """Return ``{"allowed": bool, "violations": [...]}`` for a search space. +def get_automation_model_eligibility(workspace: Workspace) -> dict: + """Return ``{"allowed": bool, "violations": [...]}`` for a workspace. Used by the eligibility endpoint and the chat tool's early check. Thin wrapper over :func:`get_model_eligibility`. """ return get_model_eligibility( - chat_model_id=search_space.chat_model_id, - image_gen_model_id=search_space.image_gen_model_id, - vision_model_id=search_space.vision_model_id, + chat_model_id=workspace.chat_model_id, + image_gen_model_id=workspace.image_gen_model_id, + vision_model_id=workspace.vision_model_id, ) class AutomationModelPolicyError(Exception): - """Raised when a search space's models are not billable for automations.""" + """Raised when a workspace's models are not billable for automations.""" def __init__(self, violations: list[dict]) -> None: self.violations = violations @@ -143,8 +143,8 @@ def assert_models_billable( raise AutomationModelPolicyError(result["violations"]) -def assert_automation_models_billable(search_space: SearchSpace) -> None: +def assert_automation_models_billable(workspace: Workspace) -> None: """Raise :class:`AutomationModelPolicyError` if any model slot is not billable.""" - result = get_automation_model_eligibility(search_space) + result = get_automation_model_eligibility(workspace) if not result["allowed"]: raise AutomationModelPolicyError(result["violations"]) diff --git a/surfsense_backend/app/automations/services/run.py b/surfsense_backend/app/automations/services/run.py index 9bcd1393e..1e130b00d 100644 --- a/surfsense_backend/app/automations/services/run.py +++ b/surfsense_backend/app/automations/services/run.py @@ -65,9 +65,9 @@ class RunService: await check_permission( self.session, self.auth, - automation.search_space_id, + automation.workspace_id, permission, - f"You don't have permission to {permission.split(':')[1]} automations in this search space", + f"You don't have permission to {permission.split(':')[1]} automations in this workspace", ) return automation diff --git a/surfsense_backend/app/automations/services/trigger.py b/surfsense_backend/app/automations/services/trigger.py index 52c827c67..5175eb15e 100644 --- a/surfsense_backend/app/automations/services/trigger.py +++ b/surfsense_backend/app/automations/services/trigger.py @@ -103,9 +103,9 @@ class TriggerService: await check_permission( self.session, self.auth, - automation.search_space_id, + automation.workspace_id, permission, - f"You don't have permission to {permission.split(':')[1]} automations in this search space", + f"You don't have permission to {permission.split(':')[1]} automations in this workspace", ) return automation diff --git a/surfsense_backend/app/automations/templating/context.py b/surfsense_backend/app/automations/templating/context.py index 96fdb02e9..654311610 100644 --- a/surfsense_backend/app/automations/templating/context.py +++ b/surfsense_backend/app/automations/templating/context.py @@ -13,7 +13,7 @@ def build_run_context( automation_id: int, automation_name: str | None, automation_version: int | None, - search_space_id: int | None, + workspace_id: int | None, creator_id: Any, trigger_id: int | None, trigger_type: str | None, @@ -29,7 +29,7 @@ def build_run_context( "automation_id": automation_id, "automation_name": automation_name, "automation_version": automation_version, - "search_space_id": search_space_id, + "workspace_id": workspace_id, "creator_id": creator_id, "trigger_id": trigger_id, "trigger_type": trigger_type, diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 405718f0c..9e69d6d53 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -839,8 +839,8 @@ class Config: # Legacy environment variables removed in favor of user-specific configurations # True when an operator-provided global_llm_config.yaml is present. - # Used to gate the per-search-space LLM onboarding flow: when a global - # config file exists, search spaces inherit it and onboarding is skipped. + # Used to gate the per-workspace LLM onboarding flow: when a global + # config file exists, workspaces inherit it and onboarding is skipped. GLOBAL_LLM_CONFIG_FILE_EXISTS = ( BASE_DIR / "app" / "config" / "global_llm_config.yaml" ).exists() diff --git a/surfsense_backend/app/connectors/google_drive/content_extractor.py b/surfsense_backend/app/connectors/google_drive/content_extractor.py index 1ea047978..9df7640eb 100644 --- a/surfsense_backend/app/connectors/google_drive/content_extractor.py +++ b/surfsense_backend/app/connectors/google_drive/content_extractor.py @@ -136,7 +136,7 @@ async def _parse_file_to_markdown( async def download_and_process_file( client: GoogleDriveClient, file: dict[str, Any], - search_space_id: int, + workspace_id: int, user_id: str, session: AsyncSession, task_logger: TaskLoggingService, @@ -149,7 +149,7 @@ async def download_and_process_file( Args: client: GoogleDriveClient instance file: File metadata from Drive API - search_space_id: ID of the search space + workspace_id: ID of the workspace user_id: ID of the user session: Database session task_logger: Task logging service @@ -246,7 +246,7 @@ async def download_and_process_file( await process_file_in_background( file_path=temp_file_path, filename=etl_filename, - search_space_id=search_space_id, + workspace_id=workspace_id, user_id=user_id, session=session, task_logger=task_logger, diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 2c9d28b58..365e56c8e 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -292,7 +292,7 @@ INCENTIVE_TASKS_CONFIG = { class Permission(StrEnum): """ - Granular permissions for search space resources. + Granular permissions for workspace resources. Use '*' (FULL_ACCESS) to grant all permissions. """ @@ -363,10 +363,10 @@ class Permission(StrEnum): ROLES_UPDATE = "roles:update" ROLES_DELETE = "roles:delete" - # Search Space Settings + # Workspace Settings SETTINGS_VIEW = "settings:view" SETTINGS_UPDATE = "settings:update" - SETTINGS_DELETE = "settings:delete" # Delete the entire search space + SETTINGS_DELETE = "settings:delete" # Delete the entire workspace # API Access API_ACCESS_MANAGE = "api_access:manage" @@ -515,7 +515,7 @@ class ChatVisibility(StrEnum): Visibility/sharing level for chat threads. PRIVATE: Only the creator can see/access the chat (default) - SEARCH_SPACE: All members of the search space can see/access the chat + SEARCH_SPACE: All members of the workspace can see/access the chat PUBLIC: (Future) Anyone with the link can access the chat """ @@ -605,8 +605,10 @@ class NewChatThread(BaseModel, TimestampMixin): ) # Foreign keys - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) # Track who created this chat thread (for visibility filtering) @@ -644,7 +646,7 @@ class NewChatThread(BaseModel, TimestampMixin): # Auto model pin for this thread: concrete resolved global LLM # config id. NULL means no pin; Auto will resolve on the next turn. # Single-writer invariant: only app.services.auto_model_pin_service sets - # or clears this column (plus bulk clears when a search space's + # or clears this column (plus bulk clears when a workspace's # chat_model_id changes). Unindexed: all reads are by primary key. pinned_llm_config_id = Column(Integer, nullable=True) @@ -661,7 +663,7 @@ class NewChatThread(BaseModel, TimestampMixin): ) # Relationships - search_space = relationship("SearchSpace", back_populates="new_chat_threads") + workspace = relationship("Workspace", back_populates="new_chat_threads") created_by = relationship("User", back_populates="new_chat_threads") messages = relationship( "NewChatMessage", @@ -785,8 +787,10 @@ class ExternalChatAccount(Base, TimestampMixin): owner_user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True ) - owner_search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True + owner_workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=True, ) is_system_account = Column( Boolean, nullable=False, default=False, server_default="false" @@ -819,8 +823,8 @@ class ExternalChatAccount(Base, TimestampMixin): ) owner = relationship("User", foreign_keys=[owner_user_id]) - owner_search_space = relationship( - "SearchSpace", foreign_keys=[owner_search_space_id] + owner_workspace = relationship( + "Workspace", foreign_keys=[owner_workspace_id] ) bindings = relationship( "ExternalChatBinding", @@ -897,8 +901,10 @@ class ExternalChatBinding(Base, TimestampMixin): user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) state = Column( SQLAlchemyEnum( @@ -948,7 +954,7 @@ class ExternalChatBinding(Base, TimestampMixin): account = relationship("ExternalChatAccount", back_populates="bindings") user = relationship("User", foreign_keys=[user_id]) - search_space = relationship("SearchSpace", foreign_keys=[search_space_id]) + workspace = relationship("Workspace", foreign_keys=[workspace_id]) new_chat_thread = relationship("NewChatThread", foreign_keys=[new_chat_thread_id]) threads = relationship( "NewChatThread", @@ -979,7 +985,7 @@ class ExternalChatBinding(Base, TimestampMixin): ), Index("ix_external_chat_bindings_user_state", "user_id", "state"), Index( - "ix_external_chat_bindings_search_space_state", "search_space_id", "state" + "ix_external_chat_bindings_workspace_state", "workspace_id", "state" ), ) @@ -1113,9 +1119,9 @@ class TokenUsage(BaseModel, TimestampMixin): nullable=True, index=True, ) - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -1129,7 +1135,7 @@ class TokenUsage(BaseModel, TimestampMixin): # Relationships thread = relationship("NewChatThread", back_populates="token_usages") message = relationship("NewChatMessage", back_populates="token_usage") - search_space = relationship("SearchSpace") + workspace = relationship("Workspace") user = relationship("User") @@ -1318,9 +1324,9 @@ class Folder(BaseModel, TimestampMixin): nullable=True, index=True, ) - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -1340,7 +1346,7 @@ class Folder(BaseModel, TimestampMixin): folder_metadata = Column("metadata", JSONB, nullable=True) parent = relationship("Folder", remote_side="Folder.id", backref="children") - search_space = relationship("SearchSpace", back_populates="folders") + workspace = relationship("Workspace", back_populates="folders") created_by = relationship("User", back_populates="folders") documents = relationship("Document", back_populates="folder", passive_deletes=True) @@ -1357,7 +1363,7 @@ class Document(BaseModel, TimestampMixin): # filesystem two files at different paths can hold identical bytes, # and the agent's ``write_file`` flow needs that semantic to support # copy / duplicate operations. Path uniqueness lives on - # ``unique_identifier_hash`` (per search space). The hash remains + # ``unique_identifier_hash`` (per workspace). The hash remains # indexed because connector indexers consult it as a change-detection # / cross-source dedup hint via :func:`check_duplicate_document`. # See migration 133. @@ -1382,8 +1388,10 @@ class Document(BaseModel, TimestampMixin): # Track when document was last updated by indexers, processors, or editor updated_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True) - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) folder_id = Column( @@ -1421,7 +1429,7 @@ class Document(BaseModel, TimestampMixin): ) # Relationships - search_space = relationship("SearchSpace", back_populates="documents") + workspace = relationship("Workspace", back_populates="documents") folder = relationship("Folder", back_populates="documents") created_by = relationship("User", back_populates="documents") connector = relationship("SearchSourceConnector", back_populates="documents") @@ -1506,10 +1514,12 @@ class VideoPresentation(BaseModel, TimestampMixin): index=True, ) - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) - search_space = relationship("SearchSpace", back_populates="video_presentations") + workspace = relationship("Workspace", back_populates="video_presentations") thread_id = Column( Integer, @@ -1533,10 +1543,12 @@ class Report(BaseModel, TimestampMixin): String(100), nullable=True ) # e.g. "executive_summary", "deep_research" - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) - search_space = relationship("SearchSpace", back_populates="reports") + workspace = relationship("Workspace", back_populates="reports") # Versioning: reports sharing the same report_group_id are versions of the same report. # For v1, report_group_id = the report's own id (set after insert). @@ -1561,14 +1573,16 @@ class Connection(BaseModel, TimestampMixin): scope = Column(SQLAlchemyEnum(ConnectionScope), nullable=False, index=True) enabled = Column(Boolean, nullable=False, default=True, server_default="true") - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=True, ) user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True ) - search_space = relationship("SearchSpace", back_populates="connections") + workspace = relationship("Workspace", back_populates="connections") user = relationship("User", back_populates="connections") models = relationship( "Model", @@ -1580,8 +1594,8 @@ class Connection(BaseModel, TimestampMixin): __table_args__ = ( CheckConstraint( - "(scope = 'GLOBAL' AND search_space_id IS NULL AND user_id IS NULL) OR " - "(scope = 'SEARCH_SPACE' AND search_space_id IS NOT NULL AND user_id IS NOT NULL) OR " + "(scope = 'GLOBAL' AND workspace_id IS NULL AND user_id IS NULL) OR " + "(scope = 'SEARCH_SPACE' AND workspace_id IS NOT NULL AND user_id IS NOT NULL) OR " "(scope = 'USER' AND user_id IS NOT NULL)", name="ck_connections_scope_owner", ), @@ -1672,8 +1686,10 @@ class ImageGeneration(BaseModel, TimestampMixin): access_token = Column(String(64), nullable=True, index=True) # Foreign keys - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) created_by_id = Column( UUID(as_uuid=True), @@ -1683,12 +1699,12 @@ class ImageGeneration(BaseModel, TimestampMixin): ) # Relationships - search_space = relationship("SearchSpace", back_populates="image_generations") + workspace = relationship("Workspace", back_populates="image_generations") created_by = relationship("User", back_populates="image_generations") -class SearchSpace(BaseModel, TimestampMixin): - __tablename__ = "searchspaces" +class Workspace(BaseModel, TimestampMixin): + __tablename__ = "workspaces" name = Column(String(100), nullable=False, index=True) description = Column(String(500), nullable=True) @@ -1709,7 +1725,7 @@ class SearchSpace(BaseModel, TimestampMixin): # Note: ID values preserve the existing convention: # - 0: Auto mode # - Negative IDs: Global virtual models from global_llm_config.yaml - # - Positive IDs: User/search-space models from the models table + # - Positive IDs: User/workspace models from the models table chat_model_id = Column( Integer, nullable=True, default=0, server_default="0" ) # For agent/chat operations, defaults to Auto mode @@ -1727,71 +1743,71 @@ class SearchSpace(BaseModel, TimestampMixin): user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) - user = relationship("User", back_populates="search_spaces") + user = relationship("User", back_populates="workspaces") folders = relationship( "Folder", - back_populates="search_space", + back_populates="workspace", order_by="Folder.position", cascade="all, delete-orphan", ) documents = relationship( "Document", - back_populates="search_space", + back_populates="workspace", order_by="Document.id", cascade="all, delete-orphan", ) new_chat_threads = relationship( "NewChatThread", - back_populates="search_space", + back_populates="workspace", order_by="NewChatThread.updated_at.desc()", cascade="all, delete-orphan", ) podcasts = relationship( "Podcast", - back_populates="search_space", + back_populates="workspace", order_by="Podcast.id.desc()", cascade="all, delete-orphan", ) video_presentations = relationship( "VideoPresentation", - back_populates="search_space", + back_populates="workspace", order_by="VideoPresentation.id.desc()", cascade="all, delete-orphan", ) reports = relationship( "Report", - back_populates="search_space", + back_populates="workspace", order_by="Report.id.desc()", cascade="all, delete-orphan", ) image_generations = relationship( "ImageGeneration", - back_populates="search_space", + back_populates="workspace", order_by="ImageGeneration.id.desc()", cascade="all, delete-orphan", ) logs = relationship( "Log", - back_populates="search_space", + back_populates="workspace", order_by="Log.id", cascade="all, delete-orphan", ) notifications = relationship( "Notification", - back_populates="search_space", + back_populates="workspace", order_by="Notification.created_at.desc()", cascade="all, delete-orphan", ) search_source_connectors = relationship( "SearchSourceConnector", - back_populates="search_space", + back_populates="workspace", order_by="SearchSourceConnector.id", cascade="all, delete-orphan", ) connections = relationship( "Connection", - back_populates="search_space", + back_populates="workspace", order_by="Connection.id", cascade="all, delete-orphan", passive_deletes=True, @@ -1799,7 +1815,7 @@ class SearchSpace(BaseModel, TimestampMixin): automations = relationship( "Automation", - back_populates="search_space", + back_populates="workspace", order_by="Automation.id", cascade="all, delete-orphan", passive_deletes=True, @@ -1807,21 +1823,21 @@ class SearchSpace(BaseModel, TimestampMixin): # RBAC relationships roles = relationship( - "SearchSpaceRole", - back_populates="search_space", - order_by="SearchSpaceRole.id", + "WorkspaceRole", + back_populates="workspace", + order_by="WorkspaceRole.id", cascade="all, delete-orphan", ) memberships = relationship( - "SearchSpaceMembership", - back_populates="search_space", - order_by="SearchSpaceMembership.id", + "WorkspaceMembership", + back_populates="workspace", + order_by="WorkspaceMembership.id", cascade="all, delete-orphan", ) invites = relationship( - "SearchSpaceInvite", - back_populates="search_space", - order_by="SearchSpaceInvite.id", + "WorkspaceInvite", + back_populates="workspace", + order_by="WorkspaceInvite.id", cascade="all, delete-orphan", ) @@ -1830,11 +1846,11 @@ class SearchSourceConnector(BaseModel, TimestampMixin): __tablename__ = "search_source_connectors" __table_args__ = ( UniqueConstraint( - "search_space_id", + "workspace_id", "user_id", "connector_type", "name", - name="uq_searchspace_user_connector_type_name", + name="uq_workspace_user_connector_type_name", ), # Mirrors migration 129; backs the ``/obsidian/connect`` upsert. Index( @@ -1881,11 +1897,13 @@ class SearchSourceConnector(BaseModel, TimestampMixin): indexing_frequency_minutes = Column(Integer, nullable=True) next_scheduled_at = Column(TIMESTAMP(timezone=True), nullable=True) - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) - search_space = relationship( - "SearchSpace", back_populates="search_source_connectors" + workspace = relationship( + "Workspace", back_populates="search_source_connectors" ) user_id = Column( @@ -1908,10 +1926,12 @@ class Log(BaseModel, TimestampMixin): ) # Service/component that generated the log log_metadata = Column(JSON, nullable=True, default={}) # Additional context data - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) - search_space = relationship("SearchSpace", back_populates="logs") + workspace = relationship("Workspace", back_populates="logs") class UserIncentiveTask(BaseModel, TimestampMixin): @@ -2024,18 +2044,18 @@ class CreditPurchase(Base, TimestampMixin): user = relationship("User", back_populates="credit_purchases") -class SearchSpaceRole(BaseModel, TimestampMixin): +class WorkspaceRole(BaseModel, TimestampMixin): """ - Custom roles that can be defined per search space. - Each search space can have multiple roles with different permission sets. + Custom roles that can be defined per workspace. + Each workspace can have multiple roles with different permission sets. """ - __tablename__ = "search_space_roles" + __tablename__ = "workspace_roles" __table_args__ = ( UniqueConstraint( - "search_space_id", + "workspace_id", "name", - name="uq_searchspace_role_name", + name="uq_workspace_role_name", ), ) @@ -2048,46 +2068,50 @@ class SearchSpaceRole(BaseModel, TimestampMixin): # System roles (Owner, Editor, Viewer) cannot be deleted is_system_role = Column(Boolean, nullable=False, default=False) - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) - search_space = relationship("SearchSpace", back_populates="roles") + workspace = relationship("Workspace", back_populates="roles") memberships = relationship( - "SearchSpaceMembership", back_populates="role", passive_deletes=True + "WorkspaceMembership", back_populates="role", passive_deletes=True ) invites = relationship( - "SearchSpaceInvite", back_populates="role", passive_deletes=True + "WorkspaceInvite", back_populates="role", passive_deletes=True ) -class SearchSpaceMembership(BaseModel, TimestampMixin): +class WorkspaceMembership(BaseModel, TimestampMixin): """ - Tracks user membership in search spaces with their assigned role. - Each user can be a member of multiple search spaces with different roles. + Tracks user membership in workspaces with their assigned role. + Each user can be a member of multiple workspaces with different roles. """ - __tablename__ = "search_space_memberships" + __tablename__ = "workspace_memberships" __table_args__ = ( UniqueConstraint( "user_id", - "search_space_id", - name="uq_user_searchspace_membership", + "workspace_id", + name="uq_user_workspace_membership", ), ) user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) role_id = Column( Integer, - ForeignKey("search_space_roles.id", ondelete="SET NULL"), + ForeignKey("workspace_roles.id", ondelete="SET NULL"), nullable=True, ) - # Indicates if this user is the original creator/owner of the search space + # Indicates if this user is the original creator/owner of the workspace is_owner = Column(Boolean, nullable=False, default=False) # Timestamp when the user joined (via invite or as creator) joined_at = Column( @@ -2098,36 +2122,38 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): # Reference to the invite used to join (null if owner/creator) invited_by_invite_id = Column( Integer, - ForeignKey("search_space_invites.id", ondelete="SET NULL"), + ForeignKey("workspace_invites.id", ondelete="SET NULL"), nullable=True, ) - user = relationship("User", back_populates="search_space_memberships") - search_space = relationship("SearchSpace", back_populates="memberships") - role = relationship("SearchSpaceRole", back_populates="memberships") + user = relationship("User", back_populates="workspace_memberships") + workspace = relationship("Workspace", back_populates="memberships") + role = relationship("WorkspaceRole", back_populates="memberships") invited_by_invite = relationship( - "SearchSpaceInvite", back_populates="used_by_memberships" + "WorkspaceInvite", back_populates="used_by_memberships" ) -class SearchSpaceInvite(BaseModel, TimestampMixin): +class WorkspaceInvite(BaseModel, TimestampMixin): """ - Invite links for search spaces. + Invite links for workspaces. Users can create invite links with specific roles that others can use to join. """ - __tablename__ = "search_space_invites" + __tablename__ = "workspace_invites" # Unique invite code (used in invite URLs) invite_code = Column(String(64), nullable=False, unique=True, index=True) - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) # Role to assign when invite is used (null means use default role) role_id = Column( Integer, - ForeignKey("search_space_roles.id", ondelete="SET NULL"), + ForeignKey("workspace_roles.id", ondelete="SET NULL"), nullable=True, ) # User who created this invite @@ -2148,11 +2174,11 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): # Optional custom name/label for the invite name = Column(String(100), nullable=True) - search_space = relationship("SearchSpace", back_populates="invites") - role = relationship("SearchSpaceRole", back_populates="invites") + workspace = relationship("Workspace", back_populates="invites") + role = relationship("WorkspaceRole", back_populates="invites") created_by = relationship("User", back_populates="created_invites") used_by_memberships = relationship( - "SearchSpaceMembership", + "WorkspaceMembership", back_populates="invited_by_invite", passive_deletes=True, ) @@ -2179,9 +2205,9 @@ class Prompt(BaseModel, TimestampMixin): nullable=False, index=True, ) - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, index=True, ) @@ -2196,7 +2222,7 @@ class Prompt(BaseModel, TimestampMixin): is_public = Column(Boolean, nullable=False, default=False) user = relationship("User") - search_space = relationship("SearchSpace") + workspace = relationship("Workspace") if config.AUTH_TYPE == "GOOGLE": @@ -2208,7 +2234,7 @@ if config.AUTH_TYPE == "GOOGLE": oauth_accounts: Mapped[list[OAuthAccount]] = relationship( "OAuthAccount", lazy="joined" ) - search_spaces = relationship("SearchSpace", back_populates="user") + workspaces = relationship("Workspace", back_populates="user") notifications = relationship( "Notification", back_populates="user", @@ -2217,13 +2243,13 @@ if config.AUTH_TYPE == "GOOGLE": ) # RBAC relationships - search_space_memberships = relationship( - "SearchSpaceMembership", + workspace_memberships = relationship( + "WorkspaceMembership", back_populates="user", cascade="all, delete-orphan", ) created_invites = relationship( - "SearchSpaceInvite", + "WorkspaceInvite", back_populates="created_by", passive_deletes=True, ) @@ -2345,7 +2371,7 @@ if config.AUTH_TYPE == "GOOGLE": else: class User(SQLAlchemyBaseUserTableUUID, Base): - search_spaces = relationship("SearchSpace", back_populates="user") + workspaces = relationship("Workspace", back_populates="user") notifications = relationship( "Notification", back_populates="user", @@ -2354,13 +2380,13 @@ else: ) # RBAC relationships - search_space_memberships = relationship( - "SearchSpaceMembership", + workspace_memberships = relationship( + "WorkspaceMembership", back_populates="user", cascade="all, delete-orphan", ) created_invites = relationship( - "SearchSpaceInvite", + "WorkspaceInvite", back_populates="created_by", passive_deletes=True, ) @@ -2508,9 +2534,9 @@ class AgentActionLog(BaseModel): nullable=True, index=True, ) - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -2579,9 +2605,9 @@ class DocumentRevision(BaseModel): nullable=True, index=True, ) - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -2620,9 +2646,9 @@ class FolderRevision(BaseModel): nullable=True, index=True, ) - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -2648,7 +2674,7 @@ class FolderRevision(BaseModel): class AgentPermissionRule(BaseModel): """Persistent permission rule consumed by :class:`PermissionMiddleware`. - Scoped at one of: search-space-wide (``user_id`` and ``thread_id`` NULL), + Scoped at one of: workspace-wide (``user_id`` and ``thread_id`` NULL), user-wide (``user_id`` set, ``thread_id`` NULL), or per-thread (``thread_id`` set). Loaded at agent build time and converted to :class:`Rule` instances inside the agent factory. @@ -2656,9 +2682,9 @@ class AgentPermissionRule(BaseModel): __tablename__ = "agent_permission_rules" - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -2687,7 +2713,7 @@ class AgentPermissionRule(BaseModel): __table_args__ = ( UniqueConstraint( - "search_space_id", + "workspace_id", "user_id", "thread_id", "permission", @@ -2865,15 +2891,15 @@ _INDEX_DEFINITIONS: list[tuple[str, str, str]] = [ "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_title_trgm ON documents USING gin (title gin_trgm_ops)", ), ( - "idx_documents_search_space_id", + "idx_documents_workspace_id", "documents", - "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_search_space_id ON documents (search_space_id)", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_workspace_id ON documents (workspace_id)", ), # Covering index for "recent documents" query — enables index-only scan. ( - "idx_documents_search_space_updated", + "idx_documents_workspace_updated", "documents", - "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_search_space_updated ON documents (search_space_id, updated_at DESC NULLS LAST) INCLUDE (id, title, document_type)", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_workspace_updated ON documents (workspace_id, updated_at DESC NULLS LAST) INCLUDE (id, title, document_type)", ), ] @@ -3034,10 +3060,10 @@ def has_all_permissions( def get_default_roles_config() -> list[dict]: """ Get the configuration for default system roles. - These roles are created automatically when a search space is created. + These roles are created automatically when a workspace is created. Only 3 roles are supported: - - Owner: Full access to everything (assigned to search space creator) + - Owner: Full access to everything (assigned to workspace creator) - Editor: Can create/update content but cannot delete, manage roles, or change settings - Viewer: Read-only access to resources (can add comments) @@ -3047,7 +3073,7 @@ def get_default_roles_config() -> list[dict]: return [ { "name": "Owner", - "description": "Full access to all search space resources and settings", + "description": "Full access to all workspace resources and settings", "permissions": DEFAULT_ROLE_PERMISSIONS["Owner"], "is_default": False, "is_system_role": True, @@ -3061,7 +3087,7 @@ def get_default_roles_config() -> list[dict]: }, { "name": "Viewer", - "description": "Read-only access to search space resources", + "description": "Read-only access to workspace resources", "permissions": DEFAULT_ROLE_PERMISSIONS["Viewer"], "is_default": False, "is_system_role": True, diff --git a/surfsense_backend/app/event_bus/__init__.py b/surfsense_backend/app/event_bus/__init__.py index da5735fe6..8cee59125 100644 --- a/surfsense_backend/app/event_bus/__init__.py +++ b/surfsense_backend/app/event_bus/__init__.py @@ -4,7 +4,7 @@ Domain-agnostic pub/sub. Producers ``await bus.publish(...)``; subscribers ``bus.subscribe(...)``. Domain modules depend on it, never the reverse. from app.event_bus import bus - await bus.publish("document.indexed", {"document_id": 42}, search_space_id=7) + await bus.publish("document.indexed", {"document_id": 42}, workspace_id=7) """ from __future__ import annotations diff --git a/surfsense_backend/app/event_bus/bus.py b/surfsense_backend/app/event_bus/bus.py index 38c93ba7c..7d89e9d4c 100644 --- a/surfsense_backend/app/event_bus/bus.py +++ b/surfsense_backend/app/event_bus/bus.py @@ -40,13 +40,13 @@ class EventBus: event_type: str, payload: dict[str, Any] | None = None, *, - search_space_id: int, + workspace_id: int, ) -> None: """Stamp an :class:`Event` and fan it out. Call after your commit.""" event = Event( event_type=event_type, payload=payload or {}, - search_space_id=search_space_id, + workspace_id=workspace_id, ) await self.dispatch(event) diff --git a/surfsense_backend/app/event_bus/event.py b/surfsense_backend/app/event_bus/event.py index 5dc3f7081..4bc346d09 100644 --- a/surfsense_backend/app/event_bus/event.py +++ b/surfsense_backend/app/event_bus/event.py @@ -25,7 +25,7 @@ class Event(BaseModel): """A published domain fact. ``event_type`` is a dotted namespace (``document.indexed``, etc). ``payload`` is - JSON-serializable. ``search_space_id`` scopes delivery. ``event_id`` and + JSON-serializable. ``workspace_id`` scopes delivery. ``event_id`` and ``occurred_at`` are engine-stamped. """ @@ -33,6 +33,6 @@ class Event(BaseModel): event_type: str payload: dict[str, Any] = Field(default_factory=dict) - search_space_id: int + workspace_id: int event_id: str = Field(default_factory=_new_event_id) occurred_at: datetime = Field(default_factory=_now) diff --git a/surfsense_backend/app/event_bus/events/document_entered_folder.py b/surfsense_backend/app/event_bus/events/document_entered_folder.py index fc4e2de14..66560e9e5 100644 --- a/surfsense_backend/app/event_bus/events/document_entered_folder.py +++ b/surfsense_backend/app/event_bus/events/document_entered_folder.py @@ -48,7 +48,7 @@ catalog.register( def payload_if_entered_folder( *, document_id: int, - search_space_id: int, + workspace_id: int, new_folder_id: int | None, previous_folder_id: int | None, folder_id_changed: bool, @@ -73,7 +73,7 @@ def payload_if_entered_folder( return { "event_type": EVENT_TYPE, - "search_space_id": search_space_id, + "workspace_id": workspace_id, "payload": { "document_id": document_id, "folder_id": new_folder_id, diff --git a/surfsense_backend/app/file_storage/api.py b/surfsense_backend/app/file_storage/api.py index 80417baaf..50a931d7e 100644 --- a/surfsense_backend/app/file_storage/api.py +++ b/surfsense_backend/app/file_storage/api.py @@ -37,9 +37,9 @@ async def _load_readable_document( 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", ) return document diff --git a/surfsense_backend/app/file_storage/keys.py b/surfsense_backend/app/file_storage/keys.py index 22eaa9473..8438cf42e 100644 --- a/surfsense_backend/app/file_storage/keys.py +++ b/surfsense_backend/app/file_storage/keys.py @@ -10,18 +10,18 @@ from app.file_storage.persistence.enums import DocumentFileKind def build_document_file_key( *, - search_space_id: int, + workspace_id: int, document_id: int, kind: DocumentFileKind, filename: str, ) -> str: """Build the storage key for one document file. - Shape: ``documents/{search_space_id}/{document_id}/{kind}/{uuid}{ext}``. + Shape: ``documents/{workspace_id}/{document_id}/{kind}/{uuid}{ext}``. """ extension = os.path.splitext(filename)[1].lower() unique = uuid.uuid4().hex return ( - f"documents/{search_space_id}/{document_id}/" + f"documents/{workspace_id}/{document_id}/" f"{kind.value.lower()}/{unique}{extension}" ) diff --git a/surfsense_backend/app/file_storage/persistence/models.py b/surfsense_backend/app/file_storage/persistence/models.py index 7433f35ed..e16774f12 100644 --- a/surfsense_backend/app/file_storage/persistence/models.py +++ b/surfsense_backend/app/file_storage/persistence/models.py @@ -29,9 +29,9 @@ class DocumentFile(BaseModel, TimestampMixin): nullable=False, index=True, ) - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) diff --git a/surfsense_backend/app/file_storage/service.py b/surfsense_backend/app/file_storage/service.py index bdadcfca3..472bef89f 100644 --- a/surfsense_backend/app/file_storage/service.py +++ b/surfsense_backend/app/file_storage/service.py @@ -27,7 +27,7 @@ async def store_document_file( session: AsyncSession, *, document_id: int, - search_space_id: int, + workspace_id: int, data: bytes, filename: str, mime_type: str | None = None, @@ -38,7 +38,7 @@ async def store_document_file( """Write bytes to storage and add a ``DocumentFile`` row to the session.""" backend = backend or get_storage_backend() key = build_document_file_key( - search_space_id=search_space_id, + workspace_id=workspace_id, document_id=document_id, kind=kind, filename=filename, @@ -47,7 +47,7 @@ async def store_document_file( record = DocumentFile( document_id=document_id, - search_space_id=search_space_id, + workspace_id=workspace_id, kind=kind, storage_backend=backend.backend_name, storage_key=key, diff --git a/surfsense_backend/app/gateway/agent_invoke.py b/surfsense_backend/app/gateway/agent_invoke.py index e03ea8c8b..8a402a665 100644 --- a/surfsense_backend/app/gateway/agent_invoke.py +++ b/surfsense_backend/app/gateway/agent_invoke.py @@ -75,7 +75,7 @@ async def call_agent_for_gateway( try: stream = stream_new_chat( user_query=user_text, - search_space_id=binding.search_space_id, + workspace_id=binding.workspace_id, chat_id=thread.id, user_id=str(user.id), needs_history_bootstrap=thread.needs_history_bootstrap, diff --git a/surfsense_backend/app/gateway/auth_invariant.py b/surfsense_backend/app/gateway/auth_invariant.py index 008250957..72c7ddf61 100644 --- a/surfsense_backend/app/gateway/auth_invariant.py +++ b/surfsense_backend/app/gateway/auth_invariant.py @@ -9,7 +9,7 @@ from app.auth.context import AuthContext from app.db import ExternalChatBinding, Permission, User from app.gateway.bindings import suspend_binding from app.observability.metrics import record_gateway_auth_invariant_failure -from app.utils.rbac import check_permission, check_search_space_access +from app.utils.rbac import check_permission, check_workspace_access class GatewaySuspendedError(RuntimeError): @@ -43,13 +43,13 @@ async def assert_authorization_invariant( auth = AuthContext.system(user, source="gateway") try: - await check_search_space_access(session, auth, binding.search_space_id) + await check_workspace_access(session, auth, binding.workspace_id) await check_permission( session, auth, - binding.search_space_id, + binding.workspace_id, Permission.CHATS_CREATE.value, - "External chat owner no longer has permission to chat in this search space", + "External chat owner no longer has permission to chat in this workspace", ) except HTTPException as exc: await _fail(session, binding, f"rbac_{exc.status_code}") diff --git a/surfsense_backend/app/gateway/bindings.py b/surfsense_backend/app/gateway/bindings.py index 821dd21ca..8feefd510 100644 --- a/surfsense_backend/app/gateway/bindings.py +++ b/surfsense_backend/app/gateway/bindings.py @@ -34,7 +34,7 @@ async def get_or_create_thread_for_binding( thread = NewChatThread( title=f"{source.title()} chat", - search_space_id=binding.search_space_id, + workspace_id=binding.workspace_id, created_by_id=binding.user_id, visibility=ChatVisibility.PRIVATE, source=source, diff --git a/surfsense_backend/app/gateway/inbox_processor.py b/surfsense_backend/app/gateway/inbox_processor.py index 7343b3a03..34c35f9d6 100644 --- a/surfsense_backend/app/gateway/inbox_processor.py +++ b/surfsense_backend/app/gateway/inbox_processor.py @@ -210,7 +210,7 @@ async def _resolve_slack_thread_binding( thread_binding = ExternalChatBinding( account_id=account.id, user_id=user_binding.user_id, - search_space_id=user_binding.search_space_id, + workspace_id=user_binding.workspace_id, state=ExternalChatBindingState.BOUND, external_peer_id=thread_peer_id, external_peer_kind=ExternalChatPeerKind.CHANNEL, @@ -272,7 +272,7 @@ async def _resolve_discord_thread_binding( thread_binding = ExternalChatBinding( account_id=account.id, user_id=user_binding.user_id, - search_space_id=user_binding.search_space_id, + workspace_id=user_binding.workspace_id, state=ExternalChatBindingState.BOUND, external_peer_id=thread_peer_id, external_peer_kind=ExternalChatPeerKind.CHANNEL, @@ -366,12 +366,12 @@ async def _dispatch_inbound_event( if ( bundle.auto_bind_owner and account.owner_user_id - and account.owner_search_space_id + and account.owner_workspace_id ): binding = ExternalChatBinding( account_id=account.id, user_id=account.owner_user_id, - search_space_id=account.owner_search_space_id, + workspace_id=account.owner_workspace_id, state=ExternalChatBindingState.BOUND, external_peer_id=parsed.external_peer_id, external_peer_kind=parsed.external_peer_kind, diff --git a/surfsense_backend/app/indexing_pipeline/adapters/file_upload_adapter.py b/surfsense_backend/app/indexing_pipeline/adapters/file_upload_adapter.py index 9a9e4e4d6..27378324b 100644 --- a/surfsense_backend/app/indexing_pipeline/adapters/file_upload_adapter.py +++ b/surfsense_backend/app/indexing_pipeline/adapters/file_upload_adapter.py @@ -16,7 +16,7 @@ class UploadDocumentAdapter: markdown_content: str, filename: str, etl_service: str, - search_space_id: int, + workspace_id: int, user_id: str, ) -> None: connector_doc = ConnectorDocument( @@ -24,7 +24,7 @@ class UploadDocumentAdapter: source_markdown=markdown_content, unique_id=filename, document_type=DocumentType.FILE, - search_space_id=search_space_id, + workspace_id=workspace_id, created_by_id=user_id, connector_id=None, should_use_code_chunker=False, @@ -59,7 +59,7 @@ class UploadDocumentAdapter: source_markdown=document.source_markdown, unique_id=document.title, document_type=document.document_type, - search_space_id=document.search_space_id, + workspace_id=document.workspace_id, created_by_id=str(document.created_by_id), connector_id=document.connector_id, should_use_code_chunker=False, diff --git a/surfsense_backend/app/indexing_pipeline/connector_document.py b/surfsense_backend/app/indexing_pipeline/connector_document.py index 1297a6b46..08b1d254d 100644 --- a/surfsense_backend/app/indexing_pipeline/connector_document.py +++ b/surfsense_backend/app/indexing_pipeline/connector_document.py @@ -10,7 +10,7 @@ class ConnectorDocument(BaseModel): source_markdown: str unique_id: str document_type: DocumentType - search_space_id: int = Field(gt=0) + workspace_id: int = Field(gt=0) should_use_code_chunker: bool = False metadata: dict = {} connector_id: int | None = None diff --git a/surfsense_backend/app/indexing_pipeline/document_hashing.py b/surfsense_backend/app/indexing_pipeline/document_hashing.py index a0716f401..2f9485107 100644 --- a/surfsense_backend/app/indexing_pipeline/document_hashing.py +++ b/surfsense_backend/app/indexing_pipeline/document_hashing.py @@ -4,21 +4,21 @@ from app.indexing_pipeline.connector_document import ConnectorDocument def compute_identifier_hash( - document_type_value: str, unique_id: str, search_space_id: int + document_type_value: str, unique_id: str, workspace_id: int ) -> str: """Return a stable SHA-256 hash from raw identity components.""" - combined = f"{document_type_value}:{unique_id}:{search_space_id}" + combined = f"{document_type_value}:{unique_id}:{workspace_id}" return hashlib.sha256(combined.encode("utf-8")).hexdigest() def compute_unique_identifier_hash(doc: ConnectorDocument) -> str: """Return a stable SHA-256 hash identifying a document by its source identity.""" return compute_identifier_hash( - doc.document_type.value, doc.unique_id, doc.search_space_id + doc.document_type.value, doc.unique_id, doc.workspace_id ) def compute_content_hash(doc: ConnectorDocument) -> str: - """Return a SHA-256 hash of the document's content scoped to its search space.""" - combined = f"{doc.search_space_id}:{doc.source_markdown}" + """Return a SHA-256 hash of the document's content scoped to its workspace.""" + combined = f"{doc.workspace_id}:{doc.source_markdown}" return hashlib.sha256(combined.encode("utf-8")).hexdigest() diff --git a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py index 30ea9d5d6..a9f2dd757 100644 --- a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py +++ b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py @@ -73,7 +73,7 @@ class PlaceholderInfo: title: str document_type: DocumentType unique_id: str - search_space_id: int + workspace_id: int connector_id: int | None created_by_id: str metadata: dict = field(default_factory=dict) @@ -111,7 +111,7 @@ class IndexingPipelineService: for p in placeholders: try: uid_hash = compute_identifier_hash( - p.document_type.value, p.unique_id, p.search_space_id + p.document_type.value, p.unique_id, p.workspace_id ) uid_hashes.setdefault(uid_hash, p) except Exception: @@ -145,7 +145,7 @@ class IndexingPipelineService: content_hash=content_hash, unique_identifier_hash=uid_hash, document_metadata=p.metadata or {}, - search_space_id=p.search_space_id, + workspace_id=p.workspace_id, connector_id=p.connector_id, created_by_id=p.created_by_id, updated_at=datetime.now(UTC), @@ -186,7 +186,7 @@ class IndexingPipelineService: continue legacy_hash = compute_identifier_hash( - legacy_type, doc.unique_id, doc.search_space_id + legacy_type, doc.unique_id, doc.workspace_id ) result = await self.session.execute( select(Document).filter(Document.unique_identifier_hash == legacy_hash) @@ -196,7 +196,7 @@ class IndexingPipelineService: continue native_hash = compute_identifier_hash( - doc.document_type.value, doc.unique_id, doc.search_space_id + doc.document_type.value, doc.unique_id, doc.workspace_id ) existing.unique_identifier_hash = native_hash existing.document_type = doc.document_type @@ -235,14 +235,14 @@ class IndexingPipelineService: seen_hashes: set[str] = set() batch_ctx = PipelineLogContext( connector_id=connector_docs[0].connector_id if connector_docs else 0, - search_space_id=connector_docs[0].search_space_id if connector_docs else 0, + workspace_id=connector_docs[0].workspace_id if connector_docs else 0, unique_id="batch", ) for connector_doc in connector_docs: ctx = PipelineLogContext( connector_id=connector_doc.connector_id, - search_space_id=connector_doc.search_space_id, + workspace_id=connector_doc.workspace_id, unique_id=connector_doc.unique_id, ) try: @@ -318,7 +318,7 @@ class IndexingPipelineService: unique_identifier_hash=unique_identifier_hash, source_markdown=connector_doc.source_markdown, document_metadata=connector_doc.metadata, - search_space_id=connector_doc.search_space_id, + workspace_id=connector_doc.workspace_id, connector_id=connector_doc.connector_id, created_by_id=connector_doc.created_by_id, updated_at=datetime.now(UTC), @@ -358,7 +358,7 @@ class IndexingPipelineService: """ ctx = PipelineLogContext( connector_id=connector_doc.connector_id, - search_space_id=connector_doc.search_space_id, + workspace_id=connector_doc.workspace_id, unique_id=connector_doc.unique_id, doc_id=document.id, ) @@ -565,13 +565,13 @@ class IndexingPipelineService: return len(new_texts) async def _enqueue_ai_sort_if_enabled(self, document: Document) -> None: - """Fire-and-forget: enqueue incremental AI sort if the search space has it enabled.""" + """Fire-and-forget: enqueue incremental AI sort if the workspace has it enabled.""" try: - from app.db import SearchSpace + from app.db import Workspace result = await self.session.execute( - select(SearchSpace.ai_file_sort_enabled).where( - SearchSpace.id == document.search_space_id + select(Workspace.ai_file_sort_enabled).where( + Workspace.id == document.workspace_id ) ) enabled = result.scalar() @@ -581,7 +581,7 @@ class IndexingPipelineService: from app.tasks.celery_tasks.document_tasks import ai_sort_document_task user_id = str(document.created_by_id) if document.created_by_id else "" - ai_sort_document_task.delay(document.search_space_id, user_id, document.id) + ai_sort_document_task.delay(document.workspace_id, user_id, document.id) except Exception: logging.getLogger(__name__).warning( "Failed to enqueue AI sort for document %s", document.id, exc_info=True diff --git a/surfsense_backend/app/indexing_pipeline/pipeline_logger.py b/surfsense_backend/app/indexing_pipeline/pipeline_logger.py index 281a92c52..1d85dcefc 100644 --- a/surfsense_backend/app/indexing_pipeline/pipeline_logger.py +++ b/surfsense_backend/app/indexing_pipeline/pipeline_logger.py @@ -7,7 +7,7 @@ logger = logging.getLogger(__name__) @dataclass class PipelineLogContext: connector_id: int | None - search_space_id: int + workspace_id: int unique_id: str # always available from ConnectorDocument doc_id: int | None = None # set once the DB row exists (index phase only) @@ -36,7 +36,7 @@ class LogMessages: def _format_context(ctx: PipelineLogContext) -> str: parts = [ f"connector_id={ctx.connector_id}", - f"search_space_id={ctx.search_space_id}", + f"workspace_id={ctx.workspace_id}", f"unique_id={ctx.unique_id}", ] if ctx.doc_id is not None: diff --git a/surfsense_backend/app/notifications/api/api.py b/surfsense_backend/app/notifications/api/api.py index 7794a5867..9bf52fa34 100644 --- a/surfsense_backend/app/notifications/api/api.py +++ b/surfsense_backend/app/notifications/api/api.py @@ -35,7 +35,7 @@ router = APIRouter(prefix="/notifications", tags=["notifications"]) @router.get("/unread-counts-batch", response_model=BatchUnreadCountResponse) async def get_unread_counts_batch( - search_space_id: int | None = Query(None, description="Filter by search space ID"), + workspace_id: int | None = Query(None, description="Filter by workspace ID"), auth: AuthContext = Depends(require_session_context), session: AsyncSession = Depends(get_async_session), ) -> BatchUnreadCountResponse: @@ -48,11 +48,11 @@ async def get_unread_counts_batch( Notification.read == False, # noqa: E712 ] - if search_space_id is not None: - # Include global (null search-space) notifications. + if workspace_id is not None: + # Include global (null workspace) notifications. base_filter.append( - (Notification.search_space_id == search_space_id) - | (Notification.search_space_id.is_(None)) + (Notification.workspace_id == workspace_id) + | (Notification.workspace_id.is_(None)) ) is_comments = Notification.type.in_(CATEGORY_TYPES["comments"]) @@ -87,7 +87,7 @@ async def get_unread_counts_batch( @router.get("/source-types", response_model=SourceTypesResponse) async def get_notification_source_types( - search_space_id: int | None = Query(None, description="Filter by search space ID"), + workspace_id: int | None = Query(None, description="Filter by workspace ID"), auth: AuthContext = Depends(require_session_context), session: AsyncSession = Depends(get_async_session), ) -> SourceTypesResponse: @@ -95,11 +95,11 @@ async def get_notification_source_types( user = auth.user base_filter = [Notification.user_id == user.id] - if search_space_id is not None: - # Include global (null search-space) notifications. + if workspace_id is not None: + # Include global (null workspace) notifications. base_filter.append( - (Notification.search_space_id == search_space_id) - | (Notification.search_space_id.is_(None)) + (Notification.workspace_id == workspace_id) + | (Notification.workspace_id.is_(None)) ) connector_type_expr = Notification.notification_metadata["connector_type"].astext @@ -156,7 +156,7 @@ async def get_notification_source_types( @router.get("/unread-count", response_model=UnreadCountResponse) async def get_unread_count( - search_space_id: int | None = Query(None, description="Filter by search space ID"), + workspace_id: int | None = Query(None, description="Filter by workspace ID"), type_filter: NotificationType | None = Query( None, alias="type", description="Filter by notification type" ), @@ -179,11 +179,11 @@ async def get_unread_count( Notification.read == False, # noqa: E712 ] - if search_space_id is not None: - # Include global (null search-space) notifications. + if workspace_id is not None: + # Include global (null workspace) notifications. base_filter.append( - (Notification.search_space_id == search_space_id) - | (Notification.search_space_id.is_(None)) + (Notification.workspace_id == workspace_id) + | (Notification.workspace_id.is_(None)) ) if type_filter: @@ -211,7 +211,7 @@ async def get_unread_count( @router.get("", response_model=NotificationListResponse) async def list_notifications( - search_space_id: int | None = Query(None, description="Filter by search space ID"), + workspace_id: int | None = Query(None, description="Filter by workspace ID"), type_filter: NotificationType | None = Query( None, alias="type", description="Filter by notification type" ), @@ -244,15 +244,15 @@ async def list_notifications( Notification.user_id == user.id ) - if search_space_id is not None: - # Include global (null search-space) notifications. + if workspace_id is not None: + # Include global (null workspace) notifications. query = query.where( - (Notification.search_space_id == search_space_id) - | (Notification.search_space_id.is_(None)) + (Notification.workspace_id == workspace_id) + | (Notification.workspace_id.is_(None)) ) count_query = count_query.where( - (Notification.search_space_id == search_space_id) - | (Notification.search_space_id.is_(None)) + (Notification.workspace_id == workspace_id) + | (Notification.workspace_id.is_(None)) ) if type_filter: diff --git a/surfsense_backend/app/notifications/api/schemas.py b/surfsense_backend/app/notifications/api/schemas.py index 727e5485a..3dbb2fb4c 100644 --- a/surfsense_backend/app/notifications/api/schemas.py +++ b/surfsense_backend/app/notifications/api/schemas.py @@ -10,7 +10,7 @@ class NotificationResponse(BaseModel): id: int user_id: str - search_space_id: int | None + workspace_id: int | None type: str title: str message: str diff --git a/surfsense_backend/app/notifications/api/transform.py b/surfsense_backend/app/notifications/api/transform.py index 8970cb0b8..14027a019 100644 --- a/surfsense_backend/app/notifications/api/transform.py +++ b/surfsense_backend/app/notifications/api/transform.py @@ -47,7 +47,7 @@ def to_response(notification: Notification) -> NotificationResponse: return NotificationResponse( id=notification.id, user_id=str(notification.user_id), - search_space_id=notification.search_space_id, + workspace_id=notification.workspace_id, type=notification.type, title=notification.title, message=notification.message, diff --git a/surfsense_backend/app/notifications/persistence/models.py b/surfsense_backend/app/notifications/persistence/models.py index 557c4bf17..ac5028207 100644 --- a/surfsense_backend/app/notifications/persistence/models.py +++ b/surfsense_backend/app/notifications/persistence/models.py @@ -34,9 +34,9 @@ class Notification(BaseModel, TimestampMixin): ), # Serves the paginated inbox list query. Index( - "ix_notifications_user_space_created", + "ix_notifications_user_workspace_created", "user_id", - "search_space_id", + "workspace_id", "created_at", ), ) @@ -47,9 +47,9 @@ class Notification(BaseModel, TimestampMixin): nullable=False, index=True, ) - search_space_id = Column( + workspace_id = Column( Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, index=True, ) @@ -69,4 +69,4 @@ class Notification(BaseModel, TimestampMixin): ) user = relationship("User", back_populates="notifications") - search_space = relationship("SearchSpace", back_populates="notifications") + workspace = relationship("Workspace", back_populates="notifications") diff --git a/surfsense_backend/app/notifications/service/base.py b/surfsense_backend/app/notifications/service/base.py index 31b378cda..8b416a380 100644 --- a/surfsense_backend/app/notifications/service/base.py +++ b/surfsense_backend/app/notifications/service/base.py @@ -27,7 +27,7 @@ class BaseNotificationHandler: session: AsyncSession, user_id: UUID, operation_id: str, - search_space_id: int | None = None, + workspace_id: int | None = None, ) -> Notification | None: """Return the notification for ``operation_id``, if one exists.""" query = select(Notification).where( @@ -35,8 +35,8 @@ class BaseNotificationHandler: Notification.type == self.notification_type, Notification.notification_metadata["operation_id"].astext == operation_id, ) - if search_space_id is not None: - query = query.where(Notification.search_space_id == search_space_id) + if workspace_id is not None: + query = query.where(Notification.workspace_id == workspace_id) result = await session.execute(query) return result.scalar_one_or_none() @@ -48,12 +48,12 @@ class BaseNotificationHandler: operation_id: str, title: str, message: str, - search_space_id: int | None = None, + workspace_id: int | None = None, initial_metadata: dict[str, Any] | None = None, ) -> Notification: """Upsert a notification keyed by ``operation_id``.""" notification = await self.find_notification_by_operation( - session, user_id, operation_id, search_space_id + session, user_id, operation_id, workspace_id ) if notification: @@ -77,7 +77,7 @@ class BaseNotificationHandler: notification = Notification( user_id=user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, type=self.notification_type, title=title, message=message, diff --git a/surfsense_backend/app/notifications/service/facade.py b/surfsense_backend/app/notifications/service/facade.py index 9f4ad50d0..29a36f6bf 100644 --- a/surfsense_backend/app/notifications/service/facade.py +++ b/surfsense_backend/app/notifications/service/facade.py @@ -38,13 +38,13 @@ class NotificationService: notification_type: str, title: str, message: str, - search_space_id: int | None = None, + workspace_id: int | None = None, notification_metadata: dict[str, Any] | None = None, ) -> Notification: """Create a generic notification of any ``notification_type``.""" notification = Notification( user_id=user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, type=notification_type, title=title, message=message, diff --git a/surfsense_backend/app/notifications/service/handlers/auto_reload_failed.py b/surfsense_backend/app/notifications/service/handlers/auto_reload_failed.py index 0234a436d..d371a96b9 100644 --- a/surfsense_backend/app/notifications/service/handlers/auto_reload_failed.py +++ b/surfsense_backend/app/notifications/service/handlers/auto_reload_failed.py @@ -30,7 +30,7 @@ class AutoReloadFailedNotificationHandler(BaseNotificationHandler): ) -> Notification: """Notify that an off-session auto-reload charge was declined. - Not tied to a search space (``search_space_id`` is None); the action + Not tied to a workspace (``workspace_id`` is None); the action links to the billing settings so the user can fix their card. """ op_id = msg.operation_id(payment_intent_id or "") @@ -42,7 +42,7 @@ class AutoReloadFailedNotificationHandler(BaseNotificationHandler): operation_id=op_id, title=title, message=message, - search_space_id=None, + workspace_id=None, initial_metadata={ "amount_micros": amount_micros, "payment_intent_id": payment_intent_id, diff --git a/surfsense_backend/app/notifications/service/handlers/comment_reply.py b/surfsense_backend/app/notifications/service/handlers/comment_reply.py index 7d9a9495a..54055bd81 100644 --- a/surfsense_backend/app/notifications/service/handlers/comment_reply.py +++ b/surfsense_backend/app/notifications/service/handlers/comment_reply.py @@ -49,7 +49,7 @@ class CommentReplyNotificationHandler(BaseNotificationHandler): author_avatar_url: str | None, author_email: str, content_preview: str, - search_space_id: int, + workspace_id: int, ) -> Notification: """Notify of a reply; idempotent on ``reply_id`` per user.""" existing = await self.find_notification_by_reply(session, reply_id, user_id) @@ -78,7 +78,7 @@ class CommentReplyNotificationHandler(BaseNotificationHandler): try: notification = Notification( user_id=user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, type=self.notification_type, title=title, message=message, diff --git a/surfsense_backend/app/notifications/service/handlers/connector_indexing.py b/surfsense_backend/app/notifications/service/handlers/connector_indexing.py index 9ebfae2ea..c656d30ec 100644 --- a/surfsense_backend/app/notifications/service/handlers/connector_indexing.py +++ b/surfsense_backend/app/notifications/service/handlers/connector_indexing.py @@ -24,7 +24,7 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler): connector_id: int, connector_name: str, connector_type: str, - search_space_id: int, + workspace_id: int, start_date: str | None = None, end_date: str | None = None, ) -> Notification: @@ -49,7 +49,7 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler): operation_id=operation_id, title=title, message=message, - search_space_id=search_space_id, + workspace_id=workspace_id, initial_metadata=metadata, ) @@ -144,7 +144,7 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler): connector_id: int, connector_name: str, connector_type: str, - search_space_id: int, + workspace_id: int, folder_count: int, file_count: int, folder_names: list[str] | None = None, @@ -178,6 +178,6 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler): operation_id=operation_id, title=title, message=message, - search_space_id=search_space_id, + workspace_id=workspace_id, initial_metadata=metadata, ) diff --git a/surfsense_backend/app/notifications/service/handlers/document_processing.py b/surfsense_backend/app/notifications/service/handlers/document_processing.py index 714c4f1aa..5a65324e7 100644 --- a/surfsense_backend/app/notifications/service/handlers/document_processing.py +++ b/surfsense_backend/app/notifications/service/handlers/document_processing.py @@ -23,11 +23,11 @@ class DocumentProcessingNotificationHandler(BaseNotificationHandler): user_id: UUID, document_type: str, document_name: str, - search_space_id: int, + workspace_id: int, file_size: int | None = None, ) -> Notification: """Open the notification when document processing is queued.""" - operation_id = msg.operation_id(document_type, document_name, search_space_id) + operation_id = msg.operation_id(document_type, document_name, workspace_id) title = msg.started_title(document_name) message = "Waiting in queue" @@ -46,7 +46,7 @@ class DocumentProcessingNotificationHandler(BaseNotificationHandler): operation_id=operation_id, title=title, message=message, - search_space_id=search_space_id, + workspace_id=workspace_id, initial_metadata=metadata, ) diff --git a/surfsense_backend/app/notifications/service/handlers/insufficient_credits.py b/surfsense_backend/app/notifications/service/handlers/insufficient_credits.py index 46124f222..b6cd12976 100644 --- a/surfsense_backend/app/notifications/service/handlers/insufficient_credits.py +++ b/surfsense_backend/app/notifications/service/handlers/insufficient_credits.py @@ -26,12 +26,12 @@ class InsufficientCreditsNotificationHandler(BaseNotificationHandler): user_id: UUID, document_name: str, document_type: str, - search_space_id: int, + workspace_id: int, balance_micros: int, required_micros: int, ) -> Notification: """Notify that a document was blocked by insufficient credit.""" - operation_id = msg.operation_id(document_name, search_space_id) + operation_id = msg.operation_id(document_name, workspace_id) title, message = msg.summary(document_name, balance_micros, required_micros) metadata = { @@ -43,13 +43,13 @@ class InsufficientCreditsNotificationHandler(BaseNotificationHandler): "status": "failed", "error_type": "insufficient_credits", # Where the inbox item links to. - "action_url": f"/dashboard/{search_space_id}/buy-more", + "action_url": f"/dashboard/{workspace_id}/buy-more", "action_label": "Buy credits", } notification = Notification( user_id=user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, type=self.notification_type, title=title, message=message, diff --git a/surfsense_backend/app/notifications/service/handlers/mention.py b/surfsense_backend/app/notifications/service/handlers/mention.py index 568dc01de..2502648a2 100644 --- a/surfsense_backend/app/notifications/service/handlers/mention.py +++ b/surfsense_backend/app/notifications/service/handlers/mention.py @@ -48,7 +48,7 @@ class MentionNotificationHandler(BaseNotificationHandler): author_avatar_url: str | None, author_email: str, content_preview: str, - search_space_id: int, + workspace_id: int, ) -> Notification: """Notify a mentioned user; idempotent on ``mention_id``.""" existing = await self.find_notification_by_mention(session, mention_id) @@ -77,7 +77,7 @@ class MentionNotificationHandler(BaseNotificationHandler): try: notification = Notification( user_id=mentioned_user_id, - search_space_id=search_space_id, + workspace_id=workspace_id, type=self.notification_type, title=title, message=message, diff --git a/surfsense_backend/app/notifications/service/messages/document_processing.py b/surfsense_backend/app/notifications/service/messages/document_processing.py index 1f324b35d..fea583f07 100644 --- a/surfsense_backend/app/notifications/service/messages/document_processing.py +++ b/surfsense_backend/app/notifications/service/messages/document_processing.py @@ -9,11 +9,11 @@ from typing import Any from app.notifications.service.messages.text import format_title -def operation_id(document_type: str, filename: str, search_space_id: int) -> str: +def operation_id(document_type: str, filename: str, workspace_id: int) -> str: """Build a unique id for a document processing run.""" timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f") filename_hash = hashlib.md5(filename.encode()).hexdigest()[:8] - return f"doc_{document_type}_{search_space_id}_{timestamp}_{filename_hash}" + return f"doc_{document_type}_{workspace_id}_{timestamp}_{filename_hash}" def started_title(document_name: str) -> str: diff --git a/surfsense_backend/app/notifications/service/messages/insufficient_credits.py b/surfsense_backend/app/notifications/service/messages/insufficient_credits.py index fad26ad91..5e8ca5299 100644 --- a/surfsense_backend/app/notifications/service/messages/insufficient_credits.py +++ b/surfsense_backend/app/notifications/service/messages/insufficient_credits.py @@ -8,11 +8,11 @@ from datetime import UTC, datetime from app.notifications.service.messages.text import truncate -def operation_id(document_name: str, search_space_id: int) -> str: +def operation_id(document_name: str, workspace_id: int) -> str: """Build a unique id for an insufficient-credits notification.""" timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f") doc_hash = hashlib.md5(document_name.encode()).hexdigest()[:8] - return f"insufficient_credits_{search_space_id}_{timestamp}_{doc_hash}" + return f"insufficient_credits_{workspace_id}_{timestamp}_{doc_hash}" def summary( diff --git a/surfsense_backend/app/observability/metrics.py b/surfsense_backend/app/observability/metrics.py index ade43ab01..4fd4e8c1f 100644 --- a/surfsense_backend/app/observability/metrics.py +++ b/surfsense_backend/app/observability/metrics.py @@ -534,12 +534,12 @@ def record_tool_call_error(*, tool_name: str) -> None: def record_kb_search_duration( - duration_ms: float, *, search_space_id: int | None, surface: str + duration_ms: float, *, workspace_id: int | None, surface: str ) -> None: _record( _kb_search_duration(), duration_ms, - {"search_space.id": search_space_id, "search.surface": surface}, + {"search_space.id": workspace_id, "search.surface": surface}, ) diff --git a/surfsense_backend/app/observability/otel.py b/surfsense_backend/app/observability/otel.py index ad2178f39..c2ec951dd 100644 --- a/surfsense_backend/app/observability/otel.py +++ b/surfsense_backend/app/observability/otel.py @@ -254,14 +254,14 @@ def model_call_span( def kb_search_span( *, - search_space_id: int | None = None, + workspace_id: int | None = None, query_chars: int | None = None, extra: dict[str, Any] | None = None, ): """Span around knowledge-base search routines.""" attrs: dict[str, Any] = {} - if search_space_id is not None: - attrs["search_space.id"] = int(search_space_id) + if workspace_id is not None: + attrs["search_space.id"] = int(workspace_id) if query_chars is not None: attrs["query.chars"] = int(query_chars) if extra: @@ -289,7 +289,7 @@ def kb_persist_span( def chat_request_span( *, chat_id: int | None = None, - search_space_id: int | None = None, + workspace_id: int | None = None, flow: str | None = None, request_id: str | None = None, turn_id: str | None = None, @@ -302,8 +302,8 @@ def chat_request_span( attrs: dict[str, Any] = {} if chat_id is not None: attrs["chat.id"] = int(chat_id) - if search_space_id is not None: - attrs["search_space.id"] = int(search_space_id) + if workspace_id is not None: + attrs["search_space.id"] = int(workspace_id) if flow: attrs["chat.flow"] = flow if request_id: diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index 582b0531e..3cf293a2f 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -22,8 +22,8 @@ from app.auth.context import AuthContext from app.config import config as app_config from app.db import ( Permission, - SearchSpace, - SearchSpaceMembership, + Workspace, + WorkspaceMembership, get_async_session, ) from app.podcasts.generation.brief import propose_brief @@ -59,7 +59,7 @@ router = APIRouter() @router.get("/podcasts", response_model=list[PodcastSummary]) async def list_podcasts( - search_space_id: int | None = None, + workspace_id: int | None = None, skip: int = 0, limit: int = 100, session: AsyncSession = Depends(get_async_session), @@ -69,11 +69,11 @@ async def list_podcasts( if skip < 0 or limit < 1: raise HTTPException(status_code=400, detail="Invalid pagination parameters") - if search_space_id is not None: - await _require(session, auth, search_space_id, Permission.PODCASTS_READ) + if workspace_id is not None: + await _require(session, auth, workspace_id, Permission.PODCASTS_READ) query = ( select(Podcast) - .where(Podcast.search_space_id == search_space_id) + .where(Podcast.workspace_id == workspace_id) .order_by(Podcast.created_at.desc()) .offset(skip) .limit(limit) @@ -81,9 +81,9 @@ async def list_podcasts( else: query = ( select(Podcast) - .join(SearchSpace) - .join(SearchSpaceMembership) - .where(SearchSpaceMembership.user_id == user.id) + .join(Workspace) + .join(WorkspaceMembership) + .where(WorkspaceMembership.user_id == user.id) .order_by(Podcast.created_at.desc()) .offset(skip) .limit(limit) @@ -159,19 +159,19 @@ async def create_podcast( session: AsyncSession = Depends(get_async_session), auth: AuthContext = Depends(get_auth_context), ): - await _require(session, auth, body.search_space_id, Permission.PODCASTS_CREATE) + await _require(session, auth, body.workspace_id, Permission.PODCASTS_CREATE) service = PodcastService(session) podcast = await service.create( title=body.title, - search_space_id=body.search_space_id, + workspace_id=body.workspace_id, thread_id=body.thread_id, ) podcast.source_content = body.source_content spec = await propose_brief( session, - search_space_id=body.search_space_id, + workspace_id=body.workspace_id, speaker_count=body.speaker_count, min_seconds=body.min_seconds, max_seconds=body.max_seconds, @@ -219,7 +219,7 @@ async def approve_brief( async with _lifecycle_errors(): await PodcastService(session).begin_drafting(podcast) await session.commit() - draft_transcript_task.delay(podcast.id, podcast.search_space_id) + draft_transcript_task.delay(podcast.id, podcast.workspace_id) return PodcastDetail.of(podcast) @@ -325,15 +325,15 @@ async def stream_podcast( async def _require( session: AsyncSession, auth: AuthContext, - search_space_id: int, + workspace_id: int, permission: Permission, ) -> None: await check_permission( session, auth, - search_space_id, + workspace_id, permission.value, - "You don't have permission for podcasts in this search space", + "You don't have permission for podcasts in this workspace", ) @@ -346,7 +346,7 @@ async def _load( podcast = await PodcastRepository(session).get(podcast_id) if podcast is None: raise HTTPException(status_code=404, detail="Podcast not found") - await _require(session, auth, podcast.search_space_id, permission) + await _require(session, auth, podcast.workspace_id, permission) return podcast diff --git a/surfsense_backend/app/podcasts/api/schemas.py b/surfsense_backend/app/podcasts/api/schemas.py index e9d6e6b0c..12b978c09 100644 --- a/surfsense_backend/app/podcasts/api/schemas.py +++ b/surfsense_backend/app/podcasts/api/schemas.py @@ -30,7 +30,7 @@ class CreatePodcastRequest(BaseModel): """Create a podcast and kick off brief proposal.""" title: str = Field(..., min_length=1, max_length=500) - search_space_id: int + workspace_id: int source_content: str = Field(..., min_length=1) thread_id: int | None = None speaker_count: int = Field(default=DEFAULT_SPEAKER_COUNT, ge=1, le=6) @@ -83,7 +83,7 @@ class PodcastSummary(BaseModel): title: str status: PodcastStatus created_at: datetime - search_space_id: int + workspace_id: int thread_id: int | None = None @@ -100,7 +100,7 @@ class PodcastDetail(BaseModel): duration_seconds: int | None error: str | None created_at: datetime - search_space_id: int + workspace_id: int thread_id: int | None @classmethod @@ -116,6 +116,6 @@ class PodcastDetail(BaseModel): duration_seconds=podcast.duration_seconds, error=podcast.error, created_at=podcast.created_at, - search_space_id=podcast.search_space_id, + workspace_id=podcast.workspace_id, thread_id=podcast.thread_id, ) diff --git a/surfsense_backend/app/podcasts/generation/brief/propose.py b/surfsense_backend/app/podcasts/generation/brief/propose.py index 09d74840e..915a03909 100644 --- a/surfsense_backend/app/podcasts/generation/brief/propose.py +++ b/surfsense_backend/app/podcasts/generation/brief/propose.py @@ -17,7 +17,7 @@ from .state import BriefState async def propose_brief( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, speaker_count: int = DEFAULT_SPEAKER_COUNT, min_seconds: int = DEFAULT_MIN_SECONDS, max_seconds: int = DEFAULT_MAX_SECONDS, @@ -25,7 +25,7 @@ async def propose_brief( ) -> PodcastSpec: """Reuse the last-used language and voices, else English; return the spec.""" last_language, last_voices = preferences_from( - await PodcastRepository(session).latest_with_spec(search_space_id) + await PodcastRepository(session).latest_with_spec(workspace_id) ) config = { "configurable": { diff --git a/surfsense_backend/app/podcasts/generation/transcript/config.py b/surfsense_backend/app/podcasts/generation/transcript/config.py index f627fc166..6c4e7d508 100644 --- a/surfsense_backend/app/podcasts/generation/transcript/config.py +++ b/surfsense_backend/app/podcasts/generation/transcript/config.py @@ -13,7 +13,7 @@ from app.podcasts.schemas import PodcastSpec class TranscriptConfig: """The approved spec and user focus that drive drafting.""" - search_space_id: int + workspace_id: int spec: PodcastSpec focus: str | None = None diff --git a/surfsense_backend/app/podcasts/generation/transcript/nodes.py b/surfsense_backend/app/podcasts/generation/transcript/nodes.py index 7b472348d..6bbbb7777 100644 --- a/surfsense_backend/app/podcasts/generation/transcript/nodes.py +++ b/surfsense_backend/app/podcasts/generation/transcript/nodes.py @@ -97,10 +97,10 @@ def finalize(state: TranscriptState, config: RunnableConfig) -> dict[str, Any]: async def _require_llm(state: TranscriptState, tc: TranscriptConfig): - llm = await get_agent_llm(state.db_session, tc.search_space_id) + llm = await get_agent_llm(state.db_session, tc.workspace_id) if llm is None: raise RuntimeError( - f"no agent LLM configured for search space {tc.search_space_id}" + f"no agent LLM configured for workspace {tc.workspace_id}" ) return llm diff --git a/surfsense_backend/app/podcasts/persistence/models.py b/surfsense_backend/app/podcasts/persistence/models.py index 6e40a8040..2c7e2a6c2 100644 --- a/surfsense_backend/app/podcasts/persistence/models.py +++ b/surfsense_backend/app/podcasts/persistence/models.py @@ -68,10 +68,12 @@ class Podcast(BaseModel, TimestampMixin): # Legacy local audio path; retained for back-compat until cutover. file_location = Column(Text, nullable=True) - search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + workspace_id = Column( + Integer, + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, ) - search_space = relationship("SearchSpace", back_populates="podcasts") + workspace = relationship("Workspace", back_populates="podcasts") thread_id = Column( Integer, diff --git a/surfsense_backend/app/podcasts/persistence/repository.py b/surfsense_backend/app/podcasts/persistence/repository.py index 04eae9ce1..d1620d136 100644 --- a/surfsense_backend/app/podcasts/persistence/repository.py +++ b/surfsense_backend/app/podcasts/persistence/repository.py @@ -28,7 +28,7 @@ class PodcastRepository: await self._session.flush() return podcast - async def latest_with_spec(self, search_space_id: int) -> Podcast | None: + async def latest_with_spec(self, workspace_id: int) -> Podcast | None: """Most recent podcast in the space that has a stored brief. Used to seed language/voice defaults for a new podcast from what the @@ -37,7 +37,7 @@ class PodcastRepository: result = await self._session.execute( select(Podcast) .where( - Podcast.search_space_id == search_space_id, + Podcast.workspace_id == workspace_id, Podcast.spec.is_not(None), ) .order_by(Podcast.created_at.desc()) diff --git a/surfsense_backend/app/podcasts/service.py b/surfsense_backend/app/podcasts/service.py index 165bc77a4..e1da27b4f 100644 --- a/surfsense_backend/app/podcasts/service.py +++ b/surfsense_backend/app/podcasts/service.py @@ -86,12 +86,12 @@ class PodcastService: self._repo = PodcastRepository(session) async def create( - self, *, title: str, search_space_id: int, thread_id: int | None = None + self, *, title: str, workspace_id: int, thread_id: int | None = None ) -> Podcast: """Create a fresh podcast in ``PENDING`` awaiting its brief.""" podcast = Podcast( title=title, - search_space_id=search_space_id, + workspace_id=workspace_id, thread_id=thread_id, status=PodcastStatus.PENDING, spec_version=1, diff --git a/surfsense_backend/app/podcasts/storage.py b/surfsense_backend/app/podcasts/storage.py index c3326460d..1c0f577dc 100644 --- a/surfsense_backend/app/podcasts/storage.py +++ b/surfsense_backend/app/podcasts/storage.py @@ -16,21 +16,21 @@ from app.podcasts.persistence import Podcast _AUDIO_CONTENT_TYPE = "audio/mpeg" -def build_audio_key(*, search_space_id: int, podcast_id: int) -> str: +def build_audio_key(*, workspace_id: int, podcast_id: int) -> str: """Object key for a podcast's audio. - Shape: ``podcasts/{search_space_id}/{podcast_id}/{uuid}.mp3``. The uuid lets + Shape: ``podcasts/{workspace_id}/{podcast_id}/{uuid}.mp3``. The uuid lets a re-render write a fresh object before the old one is purged. """ - return f"podcasts/{search_space_id}/{podcast_id}/{uuid.uuid4().hex}.mp3" + return f"podcasts/{workspace_id}/{podcast_id}/{uuid.uuid4().hex}.mp3" async def store_audio( - *, search_space_id: int, podcast_id: int, data: bytes + *, workspace_id: int, podcast_id: int, data: bytes ) -> tuple[str, str]: """Persist audio bytes and return ``(backend_name, storage_key)``.""" backend = get_storage_backend() - key = build_audio_key(search_space_id=search_space_id, podcast_id=podcast_id) + key = build_audio_key(workspace_id=workspace_id, podcast_id=podcast_id) await backend.put(key, data, content_type=_AUDIO_CONTENT_TYPE) return backend.backend_name, key diff --git a/surfsense_backend/app/podcasts/tasks/draft.py b/surfsense_backend/app/podcasts/tasks/draft.py index c5b489571..7b5a82d70 100644 --- a/surfsense_backend/app/podcasts/tasks/draft.py +++ b/surfsense_backend/app/podcasts/tasks/draft.py @@ -19,7 +19,7 @@ from app.podcasts.service import PodcastService, read_spec 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 @@ -31,10 +31,10 @@ logger = logging.getLogger(__name__) @celery_app.task(name="podcast.draft_transcript", bind=True) -def draft_transcript_task(self, podcast_id: int, search_space_id: int) -> dict: +def draft_transcript_task(self, podcast_id: int, workspace_id: int) -> dict: try: return run_async_celery_task( - lambda: _draft_transcript(podcast_id, search_space_id) + lambda: _draft_transcript(podcast_id, workspace_id) ) except Exception as exc: logger.error("Podcast %s drafting failed: %s", podcast_id, exc) @@ -43,7 +43,7 @@ def draft_transcript_task(self, podcast_id: int, search_space_id: int) -> dict: return {"status": "failed", "podcast_id": podcast_id} -async def _draft_transcript(podcast_id: int, search_space_id: int) -> dict: +async def _draft_transcript(podcast_id: int, workspace_id: int) -> dict: async with get_celery_session_maker()() as session: repo = PodcastRepository(session) service = PodcastService(session) @@ -55,8 +55,8 @@ async def _draft_transcript(podcast_id: int, search_space_id: int) -> dict: if spec is None: raise ValueError(f"podcast {podcast_id} has no approved brief") - owner_id, tier, base_model = await _resolve_agent_billing_for_search_space( - session, search_space_id, thread_id=podcast.thread_id + owner_id, tier, base_model = await _resolve_agent_billing_for_workspace( + session, workspace_id, thread_id=podcast.thread_id ) state = TranscriptState( @@ -64,7 +64,7 @@ async def _draft_transcript(podcast_id: int, search_space_id: int) -> dict: ) config = { "configurable": { - "search_space_id": search_space_id, + "workspace_id": workspace_id, "spec": spec, "focus": spec.focus, } @@ -73,7 +73,7 @@ async def _draft_transcript(podcast_id: int, search_space_id: int) -> dict: try: async with billable_call( user_id=owner_id, - search_space_id=search_space_id, + workspace_id=workspace_id, billing_tier=tier, base_model=base_model, quota_reserve_micros_override=app_config.QUOTA_DEFAULT_PODCAST_RESERVE_MICROS, diff --git a/surfsense_backend/app/podcasts/tasks/render.py b/surfsense_backend/app/podcasts/tasks/render.py index 2e550a868..7759691a9 100644 --- a/surfsense_backend/app/podcasts/tasks/render.py +++ b/surfsense_backend/app/podcasts/tasks/render.py @@ -67,7 +67,7 @@ async def _render_audio(podcast_id: int) -> dict: superseded_key = podcast.storage_key backend_name, key = await store_audio( - search_space_id=podcast.search_space_id, + workspace_id=podcast.workspace_id, podcast_id=podcast_id, data=rendered.data, ) 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..fed21913e 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -61,7 +61,7 @@ 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 .workspaces_routes import router as workspaces_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 @@ -71,7 +71,7 @@ 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 +92,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 +135,6 @@ 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 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/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..6b335a885 100644 --- a/surfsense_backend/app/routes/discord_add_connector_route.py +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -30,7 +30,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__) @@ -86,7 +86,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: @@ -333,7 +333,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 +652,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..ade17a59f 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,14 @@ async def create_documents( "pageContent": individual_document.pageContent, } process_extension_document_task.delay( - document_dict, request.search_space_id, str(user.id) + document_dict, request.workspace_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) + url, request.workspace_id, str(user.id) ) else: raise HTTPException(status_code=400, detail="Invalid document type") @@ -125,7 +125,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 +162,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 +216,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 +244,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 +288,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 +308,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 +336,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 +347,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 +363,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 +483,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 +519,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 +549,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 +644,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, @@ -676,7 +676,7 @@ async def search_documents( @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 +691,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 +704,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 +718,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 +782,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 +809,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 +839,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 +854,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 +878,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 +907,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 +927,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 +1001,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 +1048,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 +1063,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 +1071,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 +1126,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 +1171,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 +1184,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 +1205,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 +1225,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 +1238,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 +1264,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 +1284,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 +1313,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 +1371,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 +1413,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 +1452,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 +1503,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 +1537,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 +1589,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 +1613,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 +1655,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 +1671,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 +1682,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 +1721,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 +1756,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 +1768,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 +1813,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 +1829,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 +1838,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 +1866,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..189e9b433 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() @@ -173,10 +173,10 @@ async def get_editor_content( @router.get( - "/search-spaces/{search_space_id}/documents/{document_id}/download-markdown" + "/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 +188,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 +239,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 +262,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 +324,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 +339,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..af4dda737 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: @@ -519,12 +519,12 @@ async def bulk_move_documents( mismatched = [ doc.id for doc in documents - if doc.search_space_id != target.search_space_id + 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..f09186295 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,8 +886,8 @@ 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" @@ -899,7 +899,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 @@ -921,7 +921,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,7 +936,7 @@ 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"), @@ -995,10 +995,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 +1017,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 +1044,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 +1058,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 +1113,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..355afece2 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 @@ -278,7 +278,7 @@ async def get_logs_summary( result = await session.execute( select(Log) .filter( - and_(Log.search_space_id == search_space_id, Log.created_at >= since) + and_(Log.workspace_id == workspace_id, Log.created_at >= since) ) .order_by(desc(Log.created_at)) ) diff --git a/surfsense_backend/app/routes/luma_add_connector_route.py b/surfsense_backend/app/routes/luma_add_connector_route.py index 9a6f18940..84bfa41ee 100644 --- a/surfsense_backend/app/routes/luma_add_connector_route.py +++ b/surfsense_backend/app/routes/luma_add_connector_route.py @@ -23,7 +23,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 +48,10 @@ async def add_luma_connector( """ user = auth.user try: - # Check if a Luma connector already exists for this search space and user + # 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 +81,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 +123,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 +140,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 +179,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 +194,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..0623340b7 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, ) @@ -207,28 +207,28 @@ async def _resolve_role_model_id( 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( + 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 +236,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 +274,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 +304,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 +346,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 +379,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 +430,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 +451,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 +470,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 +495,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 +525,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 +546,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 +611,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 +654,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 +674,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 +684,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 +715,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) @@ -753,35 +753,35 @@ async def test_connection_model( @router.get( - "/search-spaces/{search_space_id}/model-roles", response_model=ModelRolesRead + "/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 + "/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 +789,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..6307264f9 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -45,7 +45,7 @@ from app.db import ( NewChatMessageRole, NewChatThread, Permission, - SearchSpace, + Workspace, TokenUsage, User, get_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,16 @@ 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 + workspace_query = select(Workspace).filter( + Workspace.id == thread.workspace_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_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 +593,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 +618,36 @@ 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 + # Check if user is the workspace owner (for legacy thread visibility) + workspace_query = select(Workspace).filter( + Workspace.id == workspace_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_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 +663,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 +701,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 +725,18 @@ 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 + # Check if user is the workspace owner (for legacy thread visibility) + workspace_query = select(Workspace).filter( + Workspace.id == workspace_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_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 +744,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 +772,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 +812,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 +822,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 +879,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 +971,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 +1035,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 +1104,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 +1162,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 +1178,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 +1381,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 +1552,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 +1632,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 +1730,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 +1744,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 +1791,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 +1849,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 +1901,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 +1965,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 +2234,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 +2288,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 +2390,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 +2403,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..5aa7cf8fa 100644 --- a/surfsense_backend/app/routes/obsidian_plugin_routes.py +++ b/surfsense_backend/app/routes/obsidian_plugin_routes.py @@ -22,7 +22,7 @@ from app.db import ( DocumentType, SearchSourceConnector, SearchSourceConnectorType, - SearchSpace, + Workspace, User, get_async_session, ) @@ -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..e9eaf5502 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,10 +21,10 @@ from sqlalchemy.orm import selectinload from app.auth.context import AuthContext from app.db import ( Permission, - SearchSpace, - SearchSpaceInvite, - SearchSpaceMembership, - SearchSpaceRole, + Workspace, + WorkspaceInvite, + WorkspaceMembership, + WorkspaceRole, User, get_async_session, ) @@ -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,30 +234,30 @@ 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 +271,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 +288,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 +315,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 +334,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 +365,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 +390,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 +415,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 +431,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 +471,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 +510,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 +534,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 +553,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 +578,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 +605,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 +628,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 +651,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 +690,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 +709,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 +730,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 +756,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 +778,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 +797,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 +834,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 +852,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 +875,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 +903,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 +918,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 +962,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,8 +982,8 @@ 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 + workspace_name=invite.workspace.name + if invite.workspace else "", role_name=invite.role.name if invite.role else None, is_valid=False, @@ -992,8 +992,8 @@ 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 + workspace_name=invite.workspace.name + if invite.workspace else "", role_name=invite.role.name if invite.role else None, is_valid=False, @@ -1002,8 +1002,8 @@ 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 + workspace_name=invite.workspace.name + if invite.workspace else "", role_name=invite.role.name if invite.role else None, is_valid=False, @@ -1011,7 +1011,7 @@ async def get_invite_info( ) 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 +1031,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 +1063,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 +1097,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 +1120,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..33da72417 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 @@ -170,8 +170,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,7 +181,7 @@ 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: @@ -189,18 +189,18 @@ async def create_search_source_connector( 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 +208,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 +217,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 +231,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 +244,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 +263,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 +281,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 +348,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 +390,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 +489,8 @@ 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 +499,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 +520,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 +592,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 +672,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 +682,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 +712,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 +732,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 +749,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 @@ -838,7 +838,7 @@ 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, } @@ -847,10 +847,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 +858,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 +871,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 +884,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 +900,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 +929,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 +948,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 +976,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 +995,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 +1023,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,10 +1044,10 @@ 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." @@ -1068,11 +1068,11 @@ async def index_connector_content( 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}" + f"Triggering web pages indexing for connector {connector_id} into workspace {workspace_id} from {indexing_from} to {indexing_to}" ) index_crawled_urls_task.delay( connector_id, - search_space_id, + workspace_id, str(user.id), indexing_from, indexing_to, @@ -1112,12 +1112,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 +1145,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 +1160,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 +1176,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 +1195,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 +1292,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 +1307,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 +1359,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 +1476,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 +1717,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 +1732,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 +1746,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 +1757,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 +1767,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 +1781,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 +1800,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 +1811,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 +1821,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 +1834,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 +1855,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 +1866,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 +1876,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 +1889,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 +1910,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 +1921,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 +1931,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 +1943,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 +1954,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 +1972,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 +1983,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 +1994,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 +2007,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 +2020,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 +2057,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 +2086,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 +2119,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 +2199,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 +2224,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 +2251,7 @@ async def run_onedrive_indexing( ) = await index_onedrive_files( session, connector_id, - search_space_id, + workspace_id, user_id, items_dict, ) @@ -2313,7 +2313,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 +2338,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 +2365,7 @@ async def run_dropbox_indexing( ) = await index_dropbox_files( session, connector_id, - search_space_id, + workspace_id, user_id, items_dict, ) @@ -2426,18 +2426,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 +2447,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 +2458,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 +2468,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, @@ -2481,7 +2481,7 @@ 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, + workspace_id: int, user_id: str, start_date: str, end_date: str, @@ -2492,14 +2492,14 @@ async def run_web_page_indexing_with_new_session( """ async with async_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 ) async def run_web_page_indexing( session: AsyncSession, connector_id: int, - search_space_id: int, + workspace_id: int, user_id: str, start_date: str, end_date: str, @@ -2510,7 +2510,7 @@ async def run_web_page_indexing( Args: session: Database session connector_id: ID of the webcrawler 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 @@ -2520,7 +2520,7 @@ async def run_web_page_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, @@ -2533,18 +2533,18 @@ async def run_web_page_indexing( # 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 +2554,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 +2565,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 +2575,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 +2587,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 +2598,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 +2619,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 +2629,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 +2647,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 +2660,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 +2668,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 +2693,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 +2703,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 +2727,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 +2747,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 +2757,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 +2811,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 +2865,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 +2890,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 +2937,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 +2950,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 +3060,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 +3143,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 +3152,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 +3185,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 +3195,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 +3228,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 +3238,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/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..88ffd63d1 100644 --- a/surfsense_backend/app/routes/teams_add_connector_route.py +++ b/surfsense_backend/app/routes/teams_add_connector_route.py @@ -82,7 +82,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: @@ -327,7 +327,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..2bc7a8ee1 100644 --- a/surfsense_backend/app/routes/video_presentations_routes.py +++ b/surfsense_backend/app/routes/video_presentations_routes.py @@ -19,8 +19,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext from app.db import ( Permission, - SearchSpace, - SearchSpaceMembership, + Workspace, + WorkspaceMembership, VideoPresentation, get_async_session, ) @@ -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/search_spaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py similarity index 54% rename from surfsense_backend/app/routes/search_spaces_routes.py rename to surfsense_backend/app/routes/workspaces_routes.py index 6eebaf201..721038c6f 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/workspaces_routes.py @@ -8,21 +8,21 @@ from sqlalchemy.future import select from app.auth.context import AuthContext from app.db import ( Permission, - SearchSpace, - SearchSpaceMembership, - SearchSpaceRole, + Workspace, + WorkspaceMembership, + WorkspaceRole, get_async_session, get_default_roles_config, ) from app.schemas import ( - SearchSpaceApiAccessUpdate, - SearchSpaceCreate, - SearchSpaceRead, - SearchSpaceUpdate, - SearchSpaceWithStats, + 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_search_space_access +from app.utils.rbac import check_permission, check_workspace_access logger = logging.getLogger(__name__) @@ -31,29 +31,29 @@ router = APIRouter() async def create_default_roles_and_membership( session: AsyncSession, - search_space_id: int, + workspace_id: int, owner_user_id, ) -> None: """ - Create default system roles for a search space and add the owner as a member. + Create default system roles for a workspace 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 + 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 = 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=search_space_id, + workspace_id=workspace_id, ) session.add(db_role) await session.flush() # Get the ID @@ -62,50 +62,50 @@ async def create_default_roles_and_membership( owner_role_id = db_role.id # Create owner membership - owner_membership = SearchSpaceMembership( + owner_membership = WorkspaceMembership( user_id=owner_user_id, - search_space_id=search_space_id, + workspace_id=workspace_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, +@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: - search_space_data = search_space.model_dump() + 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_search_space = SearchSpace(**search_space_data, user_id=user.id) - session.add(db_search_space) - await session.flush() # Get the search space ID + 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_search_space.id, user.id) + await create_default_roles_and_membership(session, db_workspace.id, user.id) await session.commit() - await session.refresh(db_search_space) - return db_search_space + await session.refresh(db_workspace) + return db_workspace except HTTPException: raise except Exception as e: await session.rollback() - logger.error(f"Failed to create search space: {e!s}", exc_info=True) + logger.error(f"Failed to create workspace: {e!s}", exc_info=True) raise HTTPException( - status_code=500, detail=f"Failed to create search space: {e!s}" + status_code=500, detail=f"Failed to create workspace: {e!s}" ) from e -@router.get("/searchspaces", response_model=list[SearchSpaceWithStats]) -async def read_search_spaces( +@router.get("/workspaces", response_model=list[WorkspaceWithStats]) +async def read_workspaces( skip: int = 0, limit: int = 200, owned_only: bool = False, @@ -114,73 +114,73 @@ async def read_search_spaces( ): user = auth.user """ - Get all search spaces the user has access to, with member count and ownership info. + 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 search spaces owned by the user. - If False (default), return all search spaces the user has access to. + 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 = ~SearchSpace.name.startswith("[DELETING] ") + not_deleting = ~Workspace.name.startswith("[DELETING] ") api_access_filter = ( - SearchSpace.api_access_enabled == True # noqa: E712 + Workspace.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) + # Return only workspaces 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()) + 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 search spaces the user has membership in + # Return all workspaces the user has membership in result = await session.execute( - select(SearchSpace) - .join(SearchSpaceMembership) + select(Workspace) + .join(WorkspaceMembership) .filter( - SearchSpaceMembership.user_id == user.id, + WorkspaceMembership.user_id == user.id, not_deleting, api_access_filter, ) - .order_by(SearchSpace.id.asc()) + .order_by(Workspace.id.asc()) .offset(skip) .limit(limit) ) - search_spaces = result.scalars().all() + workspaces = result.scalars().all() - # Get member counts and ownership info for each search space - search_spaces_with_stats = [] - for space in search_spaces: + # 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(SearchSpaceMembership.id)).filter( - SearchSpaceMembership.search_space_id == space.id + 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(SearchSpaceMembership).filter( - SearchSpaceMembership.search_space_id == space.id, - SearchSpaceMembership.user_id == user.id, - SearchSpaceMembership.is_owner == True, # noqa: E712 + 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 - search_spaces_with_stats.append( - SearchSpaceWithStats( + workspaces_with_stats.append( + WorkspaceWithStats( id=space.id, name=space.name, description=space.description, @@ -195,54 +195,54 @@ async def read_search_spaces( ) ) - return search_spaces_with_stats + return workspaces_with_stats except Exception as e: raise HTTPException( - status_code=500, detail=f"Failed to fetch search spaces: {e!s}" + status_code=500, detail=f"Failed to fetch workspaces: {e!s}" ) from e -@router.get("/searchspaces/{search_space_id}", response_model=SearchSpaceRead) -async def read_search_space( - search_space_id: int, +@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 search space by ID. + Get a specific workspace 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) + await check_workspace_access(session, auth, workspace_id) 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") - return search_space + return workspace except HTTPException: raise except Exception as e: raise HTTPException( - status_code=500, detail=f"Failed to fetch search space: {e!s}" + status_code=500, detail=f"Failed to fetch workspace: {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, +@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 search space. + Update a workspace. Requires SETTINGS_UPDATE permission. """ try: @@ -250,46 +250,46 @@ async def update_search_space( await check_permission( session, auth, - search_space_id, + workspace_id, Permission.SETTINGS_UPDATE.value, - "You don't have permission to update this search space", + "You don't have permission to update this workspace", ) result = await session.execute( - select(SearchSpace).filter(SearchSpace.id == search_space_id) + select(Workspace).filter(Workspace.id == workspace_id) ) - db_search_space = result.scalars().first() + db_workspace = result.scalars().first() - if not db_search_space: - raise HTTPException(status_code=404, detail="Search space not found") + if not db_workspace: + raise HTTPException(status_code=404, detail="Workspace not found") - update_data = search_space_update.model_dump(exclude_unset=True) + update_data = workspace_update.model_dump(exclude_unset=True) for key, value in update_data.items(): - setattr(db_search_space, key, value) + setattr(db_workspace, key, value) await session.commit() - await session.refresh(db_search_space) - return db_search_space + 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 search space: {e!s}" + status_code=500, detail=f"Failed to update workspace: {e!s}" ) from e @router.put( - "/searchspaces/{search_space_id}/api-access", response_model=SearchSpaceRead + "/workspaces/{workspace_id}/api-access", response_model=WorkspaceRead ) -async def update_search_space_api_access( - search_space_id: int, - body: SearchSpaceApiAccessUpdate, +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 search space. + Toggle programmatic API/PAT access for a workspace. Requires API_ACCESS_MANAGE permission. """ try: @@ -302,23 +302,23 @@ async def update_search_space_api_access( await check_permission( session, auth, - search_space_id, + workspace_id, Permission.API_ACCESS_MANAGE.value, - "You don't have permission to manage API access for this search space", + "You don't have permission to manage API access for this workspace", ) result = await session.execute( - select(SearchSpace).filter(SearchSpace.id == search_space_id) + select(Workspace).filter(Workspace.id == workspace_id) ) - db_search_space = result.scalars().first() + db_workspace = result.scalars().first() - if not db_search_space: - raise HTTPException(status_code=404, detail="Search space not found") + if not db_workspace: + raise HTTPException(status_code=404, detail="Workspace not found") - db_search_space.api_access_enabled = body.api_access_enabled + db_workspace.api_access_enabled = body.api_access_enabled await session.commit() - await session.refresh(db_search_space) - return db_search_space + await session.refresh(db_workspace) + return db_workspace except HTTPException: raise except Exception as e: @@ -328,33 +328,33 @@ async def update_search_space_api_access( ) from e -@router.post("/searchspaces/{search_space_id}/ai-sort") +@router.post("/workspaces/{workspace_id}/ai-sort") async def trigger_ai_sort( - search_space_id: int, + workspace_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.""" + """Trigger a full AI file sort for all documents in the workspace.""" try: await check_permission( session, auth, - search_space_id, + workspace_id, Permission.SETTINGS_UPDATE.value, - "You don't have permission to trigger AI sort on this search space", + "You don't have permission to trigger AI sort on this workspace", ) result = await session.execute( - select(SearchSpace).filter(SearchSpace.id == search_space_id) + select(Workspace).filter(Workspace.id == workspace_id) ) - db_search_space = result.scalars().first() - if not db_search_space: - raise HTTPException(status_code=404, detail="Search space not found") + db_workspace = result.scalars().first() + if not db_workspace: + raise HTTPException(status_code=404, detail="Workspace not found") - from app.tasks.celery_tasks.document_tasks import ai_sort_search_space_task + from app.tasks.celery_tasks.document_tasks import ai_sort_workspace_task - ai_sort_search_space_task.delay(search_space_id, str(user.id)) + ai_sort_workspace_task.delay(workspace_id, str(user.id)) return {"message": "AI sort started"} except HTTPException: raise @@ -365,14 +365,14 @@ async def trigger_ai_sort( ) from e -@router.delete("/searchspaces/{search_space_id}", response_model=dict) -async def delete_search_space( - search_space_id: int, +@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 search space. + Delete a workspace. Requires SETTINGS_DELETE permission (only owners have this by default). Heavy cascade deletion (documents, chunks, threads, etc.) is dispatched @@ -383,74 +383,74 @@ async def delete_search_space( await check_permission( session, auth, - search_space_id, + workspace_id, Permission.SETTINGS_DELETE.value, - "You don't have permission to delete this search space", + "You don't have permission to delete this workspace", ) result = await session.execute( - select(SearchSpace).filter(SearchSpace.id == search_space_id) + select(Workspace).filter(Workspace.id == workspace_id) ) - db_search_space = result.scalars().first() + db_workspace = result.scalars().first() - if not db_search_space: - raise HTTPException(status_code=404, detail="Search space not found") + if not db_workspace: + raise HTTPException(status_code=404, detail="Workspace not found") - if (db_search_space.name or "").startswith("[DELETING] "): + if (db_workspace.name or "").startswith("[DELETING] "): raise HTTPException( status_code=409, - detail="Search space is already being deleted.", + 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_search_space.name or "" - db_search_space.name = f"{prefix}{base_name[:available]}" + 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_search_space_task + from app.tasks.celery_tasks.document_tasks import delete_workspace_task - delete_search_space_task.delay(search_space_id) + delete_workspace_task.delay(workspace_id) except Exception as dispatch_error: - db_search_space.name = base_name + 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": "Search space deleted successfully"} + 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 search space: {e!s}" + status_code=500, detail=f"Failed to delete workspace: {e!s}" ) from e -@router.get("/searchspaces/{search_space_id}/snapshots") -async def list_search_space_snapshots( - search_space_id: int, +@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 search space. + 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_search_space + from app.services.public_chat_service import list_snapshots_for_workspace - snapshots = await list_snapshots_for_search_space( + snapshots = await list_snapshots_for_workspace( session=session, - search_space_id=search_space_id, + 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..e9a62e5ec 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,13 +103,13 @@ from .search_source_connector import ( SearchSourceConnectorRead, SearchSourceConnectorUpdate, ) -from .search_space import ( - SearchSpaceApiAccessUpdate, - SearchSpaceBase, - SearchSpaceCreate, - SearchSpaceRead, - SearchSpaceUpdate, - SearchSpaceWithStats, +from .workspace import ( + WorkspaceApiAccessUpdate, + WorkspaceBase, + WorkspaceCreate, + WorkspaceRead, + WorkspaceUpdate, + WorkspaceWithStats, ) from .stripe import ( CreateCreditCheckoutSessionRequest, @@ -242,13 +242,13 @@ __all__ = [ "SearchSourceConnectorCreate", "SearchSourceConnectorRead", "SearchSourceConnectorUpdate", - "SearchSpaceApiAccessUpdate", - # Search space schemas - "SearchSpaceBase", - "SearchSpaceCreate", - "SearchSpaceRead", - "SearchSpaceUpdate", - "SearchSpaceWithStats", + "WorkspaceApiAccessUpdate", + # Workspace schemas + "WorkspaceBase", + "WorkspaceCreate", + "WorkspaceRead", + "WorkspaceUpdate", + "WorkspaceWithStats", "StripeWebhookResponse", "ThreadHistoryLoadResponse", "ThreadListItem", @@ -257,7 +257,7 @@ __all__ = [ # User schemas "UserCreate", "UserRead", - "UserSearchSpaceAccess", + "UserWorkspaceAccess", "UserUpdate", "VerifyConnectionResponse", # Video Presentation schemas 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 71% rename from surfsense_backend/app/schemas/search_space.py rename to surfsense_backend/app/schemas/workspace.py index d74c46716..15c754e81 100644 --- a/surfsense_backend/app/schemas/search_space.py +++ b/surfsense_backend/app/schemas/workspace.py @@ -6,17 +6,17 @@ 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 @@ -24,11 +24,11 @@ class SearchSpaceUpdate(BaseModel): 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 @@ -41,8 +41,8 @@ class SearchSpaceRead(SearchSpaceBase, IDModel, TimestampModel): 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 index 1bf4d325e..cc4727608 100644 --- a/surfsense_backend/app/services/ai_file_sort_service.py +++ b/surfsense_backend/app/services/ai_file_sort_service.py @@ -271,7 +271,7 @@ async def ai_sort_document( leaf_folder = await ensure_folder_hierarchy_with_depth_validation( session, - document.search_space_id, + document.workspace_id, segments, ) @@ -282,13 +282,13 @@ async def ai_sort_document( async def ai_sort_all_documents( session: AsyncSession, - search_space_id: int, + workspace_id: int, llm, ) -> tuple[int, int]: - """Sort all documents in a search space. Returns (sorted_count, failed_count).""" + """Sort all documents in a workspace. Returns (sorted_count, failed_count).""" stmt = ( select(Document) - .where(Document.search_space_id == search_space_id) + .where(Document.workspace_id == workspace_id) .options(selectinload(Document.connector)) ) result = await session.execute(stmt) @@ -310,7 +310,7 @@ async def ai_sort_all_documents( leaf_folder = await ensure_folder_hierarchy_with_depth_validation( session, - search_space_id, + workspace_id, segments, ) doc.folder_id = leaf_folder.id @@ -321,8 +321,8 @@ async def ai_sort_all_documents( await session.commit() logger.info( - "AI sort complete for search_space=%d: sorted=%d, failed=%d", - search_space_id, + "AI sort complete for workspace=%d: sorted=%d, failed=%d", + workspace_id, 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..9529dd493 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,22 @@ 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" + 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 +510,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 +546,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 +558,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..dfe25c267 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -17,7 +17,7 @@ from app.db import ( NewChatMessageRole, NewChatThread, Permission, - SearchSpaceMembership, + WorkspaceMembership, User, has_permission, ) @@ -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..a13a9b3ea 100644 --- a/surfsense_backend/app/services/connector_service.py +++ b/surfsense_backend/app/services/connector_service.py @@ -26,11 +26,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,22 +40,22 @@ 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}") @@ -65,7 +65,7 @@ class ConnectorService: async def search_crawled_urls( 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, @@ -77,7 +77,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 @@ -87,7 +87,7 @@ class ConnectorService: """ crawled_urls_docs = await self._combined_rrf_search( query_text=user_query, - search_space_id=search_space_id, + workspace_id=workspace_id, document_type="CRAWLED_URL", top_k=top_k, start_date=start_date, @@ -155,7 +155,7 @@ class ConnectorService: 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 +167,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 +177,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 +221,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 +243,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 +289,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 +388,7 @@ class ConnectorService: time.perf_counter() - t0, len(combined_results), document_type, - search_space_id, + workspace_id, ) return combined_results @@ -458,20 +458,20 @@ 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, ) @@ -479,14 +479,14 @@ class ConnectorService: return result.scalars().first() async def search_tavily( - self, user_query: str, search_space_id: int, top_k: int = 20 + self, user_query: str, workspace_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 + workspace_id: The workspace ID top_k: Maximum number of results to return Returns: @@ -494,7 +494,7 @@ class ConnectorService: """ # Get Tavily connector configuration tavily_connector = await self.get_connector_by_type( - SearchSourceConnectorType.TAVILY_API, search_space_id + SearchSourceConnectorType.TAVILY_API, workspace_id ) if not tavily_connector: @@ -587,7 +587,7 @@ class ConnectorService: async def search_searxng( self, user_query: str, - search_space_id: int, + workspace_id: int, top_k: int = 20, ) -> tuple: """Search using the platform SearXNG instance. @@ -614,7 +614,7 @@ class ConnectorService: async def search_baidu( self, user_query: str, - search_space_id: int, + workspace_id: int, top_k: int = 20, ) -> tuple: """ @@ -625,7 +625,7 @@ class ConnectorService: Args: user_query: User's search query - search_space_id: Search space ID + workspace_id: Workspace ID top_k: Maximum number of results to return Returns: @@ -633,7 +633,7 @@ class ConnectorService: """ # Get Baidu connector configuration baidu_connector = await self.get_connector_by_type( - SearchSourceConnectorType.BAIDU_SEARCH_API, search_space_id + SearchSourceConnectorType.BAIDU_SEARCH_API, workspace_id ) if not baidu_connector: @@ -838,7 +838,7 @@ class ConnectorService: 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 +850,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 +860,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 +912,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 +924,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 +934,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 +982,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 +994,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 +1004,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 +1079,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 +1091,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 +1101,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 +1160,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 +1172,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 +1182,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 +1219,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 +1231,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 +1241,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 +1319,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 +1331,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 +1341,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 +1421,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 +1433,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 +1443,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 +1527,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 +1539,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 +1549,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 +1603,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 +1615,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 +1625,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 +1703,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 +1715,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 +1725,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 +1803,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 +1815,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 +1825,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 +1874,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 +1886,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 +1896,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, @@ -1968,7 +1968,7 @@ class ConnectorService: async def search_linkup( self, user_query: str, - search_space_id: int, + workspace_id: int, mode: str = "standard", ) -> tuple: """ @@ -1976,7 +1976,7 @@ class ConnectorService: Args: user_query: The user's query - search_space_id: The search space ID + workspace_id: The workspace ID mode: Search depth mode, can be "standard" or "deep" Returns: @@ -1984,7 +1984,7 @@ class ConnectorService: """ # Get Linkup connector configuration linkup_connector = await self.get_connector_by_type( - SearchSourceConnectorType.LINKUP_API, search_space_id + SearchSourceConnectorType.LINKUP_API, workspace_id ) if not linkup_connector: @@ -2089,7 +2089,7 @@ class ConnectorService: 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 +2101,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 +2111,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 +2164,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 +2176,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 +2186,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 +2238,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 +2250,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 +2260,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 +2343,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 +2355,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 +2365,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 +2429,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 +2441,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 +2451,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 +2498,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 +2511,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 +2521,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 +2572,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 +2584,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 +2594,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 +2672,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 +2684,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 +2694,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 +2766,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 +2828,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 +2850,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 +2858,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 +2867,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 +2923,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 +2952,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 +2967,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..fd31e41d3 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 @@ -175,11 +175,11 @@ 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 + 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..49deb0113 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'. @@ -214,7 +214,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 + 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..3991c3e89 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,15 +454,15 @@ 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", ) @@ -470,7 +470,7 @@ async def get_vision_llm( logger.error("No vision-capable models available for Auto mode") return None config_id = int( - choose_auto_model_candidate(candidates, search_space_id)["id"] + choose_auto_model_candidate(candidates, workspace_id)["id"] ) if config_id < 0: @@ -504,7 +504,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 +513,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 @@ -533,7 +533,7 @@ async def get_vision_llm( except Exception as e: logger.error( - f"Error getting vision LLM for search space {search_space_id}: {e!s}" + f"Error getting vision LLM for workspace {workspace_id}: {e!s}" ) return None @@ -551,7 +551,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/memory/service.py b/surfsense_backend/app/services/memory/service.py index feca000c9..31169e55c 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 Workspace, User 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..efb09c7c2 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 { @@ -85,7 +85,7 @@ class NotionToolMetadataService: } parent_pages = await self._get_parent_pages_by_account( - search_space_id, accounts + workspace_id, accounts ) accounts_with_status = [] @@ -123,7 +123,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 +132,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 +202,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 +243,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 +252,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/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 11c57e969..303c9abe8 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -31,7 +31,7 @@ from app.db import ( PodcastStatus, PublicChatSnapshot, Report, - SearchSpaceMembership, + WorkspaceMembership, User, VideoPresentation, VideoPresentationStatus, @@ -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/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/connector_tasks.py b/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py index 50f757473..a3490e8b4 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,7 +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 + session, connector_id, workspace_id, user_id, start_date, end_date ) @@ -419,7 +419,7 @@ async def _index_elasticsearch_documents( def index_crawled_urls_task( self, connector_id: int, - search_space_id: int, + workspace_id: int, user_id: str, start_date: str, end_date: str, @@ -428,7 +428,7 @@ def index_crawled_urls_task( try: return run_async_celery_task( lambda: _index_crawled_urls( - 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: @@ -438,7 +438,7 @@ def index_crawled_urls_task( async def _index_crawled_urls( connector_id: int, - search_space_id: int, + workspace_id: int, user_id: str, start_date: str, end_date: str, @@ -450,7 +450,7 @@ async def _index_crawled_urls( 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 +458,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 +466,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 +485,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 +493,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 +501,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 +521,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..ba8a49f67 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py @@ -210,18 +210,18 @@ 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.""" +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_search_space_background(search_space_id) + 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 +231,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 +243,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 +254,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 +262,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 +303,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 +317,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 +343,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: @@ -406,24 +406,24 @@ async def _process_extension_document( @celery_app.task(name="process_youtube_video", bind=True) -def process_youtube_video_task(self, url: str, search_space_id: int, user_id: str): +def process_youtube_video_task(self, url: str, workspace_id: int, user_id: str): """ Celery task to process YouTube video. Args: url: YouTube video URL - 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_youtube_video(url, search_space_id, user_id) + lambda: _process_youtube_video(url, workspace_id, user_id) ) -async def _process_youtube_video(url: str, search_space_id: int, user_id: str): +async def _process_youtube_video(url: str, workspace_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) + task_logger = TaskLoggingService(session, workspace_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 @@ -435,7 +435,7 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str): user_id=UUID(user_id), document_type="YOUTUBE_VIDEO", document_name=f"YouTube: {video_name}", - search_space_id=search_space_id, + workspace_id=workspace_id, ) ) @@ -460,7 +460,7 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str): ) result = await add_youtube_video_document( - session, url, search_space_id, user_id, notification=notification + session, url, workspace_id, user_id, notification=notification ) if result: @@ -532,7 +532,7 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str): @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 +540,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 +567,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 +581,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 +590,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 +611,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 +642,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 +709,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 +770,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 +786,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 +817,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 +852,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 +879,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 +910,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 +958,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 +1033,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 +1092,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 +1103,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 +1112,7 @@ def process_circleback_meeting_task( meeting_name, markdown_content, metadata, - search_space_id, + workspace_id, connector_id, ) ) @@ -1123,7 +1123,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 +1132,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 +1147,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 +1185,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 +1261,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 +1273,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 +1286,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 +1317,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 +1343,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 +1402,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 +1413,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 +1425,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 +1449,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 +1474,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, @@ -1539,26 +1539,26 @@ def _get_ai_sort_redis(): return _ai_sort_redis -def _ai_sort_lock_key(search_space_id: int) -> str: - return f"ai_sort:search_space:{search_space_id}:lock" +def _ai_sort_lock_key(workspace_id: int) -> str: + return f"ai_sort:workspace:{workspace_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.""" +def ai_sort_workspace_task(self, workspace_id: int, user_id: str): + """Full AI sort for all documents in a workspace.""" return run_async_celery_task( - lambda: _ai_sort_search_space_async(search_space_id, user_id) + lambda: _ai_sort_workspace_async(workspace_id, user_id) ) -async def _ai_sort_search_space_async(search_space_id: int, user_id: str): +async def _ai_sort_workspace_async(workspace_id: int, user_id: str): r = _get_ai_sort_redis() - lock_key = _ai_sort_lock_key(search_space_id) + lock_key = _ai_sort_lock_key(workspace_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, + "AI sort already running for workspace=%d, skipping", + workspace_id, ) return @@ -1568,21 +1568,21 @@ async def _ai_sort_search_space_async(search_space_id: int, user_id: str): 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) + llm = await get_agent_llm(session, workspace_id, disable_streaming=True) if llm is None: logger.warning( - "No LLM configured for search_space=%d, skipping AI sort", - search_space_id, + "No LLM configured for workspace=%d, skipping AI sort", + workspace_id, ) return sorted_count, failed_count = await ai_sort_all_documents( - session, search_space_id, llm + session, workspace_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, + "AI sort workspace=%d done in %.1fs: sorted=%d failed=%d", + workspace_id, elapsed, sorted_count, failed_count, @@ -1594,14 +1594,14 @@ async def _ai_sort_search_space_async(search_space_id: int, user_id: str): @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): +def ai_sort_document_task(self, workspace_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) + lambda: _ai_sort_document_async(workspace_id, user_id, document_id) ) -async def _ai_sort_document_async(search_space_id: int, user_id: str, document_id: int): +async def _ai_sort_document_async(workspace_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 @@ -1612,11 +1612,11 @@ async def _ai_sort_document_async(search_space_id: int, user_id: str, document_i logger.warning("Document %d not found, skipping AI sort", document_id) return - llm = await get_agent_llm(session, search_space_id, disable_streaming=True) + llm = await get_agent_llm(session, workspace_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, + "No LLM for workspace=%d, skipping AI sort of doc=%d", + workspace_id, document_id, ) return @@ -1624,7 +1624,7 @@ async def _ai_sort_document_async(search_space_id: int, user_id: str, document_i await ai_sort_document(session, document, llm) await session.commit() logger.info( - "AI sorted document=%d into search_space=%d", + "AI sorted document=%d into workspace=%d", document_id, - search_space_id, + workspace_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..7243522d7 100644 --- a/surfsense_backend/app/tasks/celery_tasks/schedule_checker_task.py +++ b/surfsense_backend/app/tasks/celery_tasks/schedule_checker_task.py @@ -142,7 +142,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, @@ -180,7 +180,7 @@ async def _check_and_trigger_schedules(): if urls: task.delay( connector.id, - connector.search_space_id, + connector.workspace_id, str(connector.user_id), None, # start_date None, # end_date @@ -202,7 +202,7 @@ async def _check_and_trigger_schedules(): 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..d46bee689 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,17 @@ 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", + "workspace=%s: %s", video_pres.id, - search_space_id, + workspace_id, resolve_err, ) video_pres.status = VideoPresentationStatus.FAILED @@ -144,7 +144,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 +157,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..49c442695 100644 --- a/surfsense_backend/app/tasks/chat/streaming/agent/builder.py +++ b/surfsense_backend/app/tasks/chat/streaming/agent/builder.py @@ -22,7 +22,7 @@ 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, @@ -38,7 +38,7 @@ 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, 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..093b5bdbe 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 @@ -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; " @@ -377,7 +377,7 @@ 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 + session, workspace_id=workspace_id ) _perf_log.info( "[stream_new_chat] Connector service + firecrawl key in %.3fs", @@ -403,7 +403,7 @@ 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, @@ -427,7 +427,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 +592,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 +632,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,7 +658,7 @@ 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, @@ -683,7 +683,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 +700,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 +793,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 +826,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..2bd0572ef 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 @@ -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; " @@ -315,7 +315,7 @@ async def stream_resume_chat( _t0 = time.perf_counter() connector_service, firecrawl_api_key = await setup_connector_and_firecrawl( - session, search_space_id=search_space_id + session, workspace_id=workspace_id ) _perf_log.info( "[stream_resume] Connector service + firecrawl key in %.3fs", @@ -337,7 +337,7 @@ 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, @@ -423,7 +423,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 +456,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,7 +473,7 @@ 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, @@ -497,7 +497,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 +511,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 +572,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 +595,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..5622a42b8 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 @@ -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..a66a9e12a 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,11 @@ 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 +68,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 +79,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 +90,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..8c27aa935 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 @@ -12,7 +12,7 @@ from app.services.connector_service import ConnectorService async def setup_connector_and_firecrawl( session: AsyncSession, *, - search_space_id: int, + workspace_id: int, ) -> tuple[ConnectorService, str | None]: """Build the per-turn connector service and pull the firecrawl API key. @@ -20,10 +20,10 @@ async def setup_connector_and_firecrawl( ``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) + connector_service = ConnectorService(session, workspace_id=workspace_id) firecrawl_api_key: str | None = None webcrawler_connector = await connector_service.get_connector_by_type( - SearchSourceConnectorType.WEBCRAWLER_CONNECTOR, search_space_id + SearchSourceConnectorType.WEBCRAWLER_CONNECTOR, workspace_id ) if webcrawler_connector and webcrawler_connector.config: firecrawl_api_key = webcrawler_connector.config.get("FIRECRAWL_API_KEY") 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/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/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 index d81de67c0..2390804a4 100644 --- a/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/webcrawler_indexer.py @@ -44,7 +44,7 @@ HEARTBEAT_INTERVAL_SECONDS = 30 async def index_crawled_urls( 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, @@ -61,7 +61,7 @@ async def index_crawled_urls( Args: session: Database session connector_id: ID of the webcrawler 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 filtering (YYYY-MM-DD format) - optional end_date: End date for filtering (YYYY-MM-DD format) - optional @@ -71,7 +71,7 @@ async def index_crawled_urls( 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( @@ -184,7 +184,7 @@ async def index_crawled_urls( try: # Generate unique identifier hash for this URL unique_identifier_hash = generate_unique_identifier_hash( - DocumentType.CRAWLED_URL, url, search_space_id + DocumentType.CRAWLED_URL, url, workspace_id ) # Check if document with this unique identifier already exists @@ -220,7 +220,7 @@ async def index_crawled_urls( # Create new document with PENDING status (visible in UI immediately) document = Document( - search_space_id=search_space_id, + workspace_id=workspace_id, title=url[:100], # Placeholder - URL as title (truncated) document_type=DocumentType.CRAWLED_URL, document_metadata={ @@ -325,7 +325,7 @@ async def index_crawled_urls( crawl_result, exclude_metadata=True ) content_hash = generate_content_hash( - structured_document_for_hash, search_space_id + structured_document_for_hash, workspace_id ) # Extract useful metadata @@ -500,15 +500,15 @@ async def index_crawled_urls( async def get_crawled_url_documents( session: AsyncSession, - search_space_id: int, + workspace_id: int, connector_id: int | None = None, ) -> list[Document]: """ - Get all crawled URL documents for a search space. + Get all crawled URL documents for a workspace. Args: session: Database session - search_space_id: ID of the search space + workspace_id: ID of the workspace connector_id: Optional connector ID to filter by Returns: @@ -517,7 +517,7 @@ async def get_crawled_url_documents( from sqlalchemy import select query = select(Document).filter( - Document.search_space_id == search_space_id, + Document.workspace_id == workspace_id, Document.document_type == DocumentType.CRAWLED_URL, ) 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..7453fabed 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( @@ -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( @@ -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 index dde5e4222..255ba4f7e 100644 --- a/surfsense_backend/app/tasks/document_processors/youtube_processor.py +++ b/surfsense_backend/app/tasks/document_processors/youtube_processor.py @@ -63,7 +63,7 @@ def get_youtube_video_id(url: str) -> str | None: async def add_youtube_video_document( session: AsyncSession, url: str, - search_space_id: int, + workspace_id: int, user_id: str, notification=None, ) -> Document: @@ -77,7 +77,7 @@ async def add_youtube_video_document( 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 + workspace_id: ID of the workspace 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 @@ -91,7 +91,7 @@ async def add_youtube_video_document( SQLAlchemyError: If there's a database error RuntimeError: If the video processing fails """ - task_logger = TaskLoggingService(session, search_space_id) + task_logger = TaskLoggingService(session, workspace_id) # Log task start log_entry = await task_logger.log_task_start( @@ -125,7 +125,7 @@ async def add_youtube_video_document( # Generate unique identifier hash for this YouTube video unique_identifier_hash = generate_unique_identifier_hash( - DocumentType.YOUTUBE_VIDEO, video_id, search_space_id + DocumentType.YOUTUBE_VIDEO, video_id, workspace_id ) # Check if document with this unique identifier already exists @@ -181,7 +181,7 @@ async def add_youtube_video_document( embedding=None, chunks=[], # Empty at creation status=DocumentStatus.pending(), # PENDING status - visible in UI - search_space_id=search_space_id, + workspace_id=workspace_id, updated_at=get_current_timestamp(), created_by_id=user_id, ) @@ -346,7 +346,7 @@ async def add_youtube_video_document( combined_document_string = "\n".join(document_parts) # Generate content hash - content_hash = generate_content_hash(combined_document_string, search_space_id) + content_hash = generate_content_hash(combined_document_string, workspace_id) # For existing documents, check if content has changed if not is_new_document and existing_document.content_hash == content_hash: diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index bf9ec74d1..c3e58cea9 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -22,9 +22,9 @@ from app.auth.session_cookies import access_expires_at, write_session from app.config import config from app.db import ( Prompt, - SearchSpace, - SearchSpaceMembership, - SearchSpaceRole, + Workspace, + WorkspaceMembership, + WorkspaceRole, User, async_session_maker, get_async_session, @@ -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,11 +204,11 @@ 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}" + f"Failed to create default workspace for user {user.id}: {e}" ) async def on_after_forgot_password( @@ -384,7 +384,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/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/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..5cce245f8 100644 --- a/surfsense_backend/app/utils/periodic_scheduler.py +++ b/surfsense_backend/app/utils/periodic_scheduler.py @@ -29,7 +29,7 @@ CONNECTOR_TASK_MAP = { 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 +44,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) @@ -94,7 +94,7 @@ def create_periodic_schedule( # 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 +133,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 +146,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 +160,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/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..215e78ab7 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -13,59 +13,59 @@ import validators from fastapi import HTTPException -def validate_search_space_id(search_space_id: Any) -> int: +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(): + if not workspace_id.strip(): raise HTTPException( - status_code=400, detail="search_space_id cannot be empty" + 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", ) diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py index c16f27087..574b570b8 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", ] 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/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/chat/test_append_message_recovery.py b/surfsense_backend/tests/integration/chat/test_append_message_recovery.py index c6a40c356..b07d60b8f 100644 --- a/surfsense_backend/tests/integration/chat/test_append_message_recovery.py +++ b/surfsense_backend/tests/integration/chat/test_append_message_recovery.py @@ -46,7 +46,7 @@ from app.db import ( NewChatMessage, NewChatMessageRole, NewChatThread, - SearchSpace, + Workspace, TokenUsage, User, ) @@ -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..bea2b71dd 100644 --- a/surfsense_backend/tests/integration/chat/test_message_id_sse.py +++ b/surfsense_backend/tests/integration/chat/test_message_id_sse.py @@ -48,7 +48,7 @@ from app.db import ( NewChatMessage, NewChatMessageRole, NewChatThread, - SearchSpace, + Workspace, User, ) from app.services.new_streaming_service import VercelStreamingService @@ -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..8282ab287 100644 --- a/surfsense_backend/tests/integration/chat/test_persistence.py +++ b/surfsense_backend/tests/integration/chat/test_persistence.py @@ -38,7 +38,7 @@ from app.db import ( NewChatMessage, NewChatMessageRole, NewChatThread, - SearchSpace, + Workspace, TokenUsage, User, ) @@ -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..f843a0684 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,9 +19,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext from app.db import ( ChatVisibility, - SearchSpace, - SearchSpaceMembership, - SearchSpaceRole, + Workspace, + WorkspaceMembership, + WorkspaceRole, User, ) from app.routes import new_chat_routes @@ -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..0c95f0c4a 100644 --- a/surfsense_backend/tests/integration/composio/test_oauth_callback.py +++ b/surfsense_backend/tests/integration/composio/test_oauth_callback.py @@ -9,7 +9,7 @@ from app.config import config from app.db import ( SearchSourceConnector, SearchSourceConnectorType, - SearchSpace, + Workspace, User, ) 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,14 @@ 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?" + 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 +73,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 +86,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 +94,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 +104,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..5ca01d148 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,24 @@ 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 + 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..ee8f8c4c8 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,28 @@ 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 + 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 +252,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 +260,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 +268,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 +292,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 +301,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 +317,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 +325,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..8c9d4d9c1 100644 --- a/surfsense_backend/tests/integration/google_unification/conftest.py +++ b/surfsense_backend/tests/integration/google_unification/conftest.py @@ -18,7 +18,7 @@ from app.db import ( DocumentType, SearchSourceConnector, SearchSourceConnectorType, - SearchSpace, + Workspace, User, ) @@ -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,7 +275,7 @@ async def seed_connector( session.add(user) await session.flush() - space = SearchSpace( + space = Workspace( name=f"{name_prefix} {uuid.uuid4().hex[:6]}", user_id=user.id ) session.add(space) @@ -287,7 +287,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 +297,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..23829eebd 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() @@ -120,7 +120,7 @@ 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 + db_session, db_workspace, db_user, mocker ): """Content hash is recomputed after reindexing with new source markdown.""" adapter = UploadDocumentAdapter(db_session) @@ -128,12 +128,12 @@ async def test_reindex_updates_content_hash( 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 +148,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 +174,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 +186,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 +212,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 +220,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 +241,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 +250,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 +269,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 +281,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..d93a4c483 100644 --- a/surfsense_backend/tests/integration/indexing_pipeline/test_index_editions.py +++ b/surfsense_backend/tests/integration/indexing_pipeline/test_index_editions.py @@ -50,13 +50,13 @@ 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 + workspace_id=db_workspace.id, source_markdown=_V1 ) document = await _index(service, doc_v1) @@ -65,7 +65,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,14 +94,14 @@ 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 + workspace_id=db_workspace.id, source_markdown=_V1 ), ) ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)} @@ -109,7 +109,7 @@ async def test_head_insert_shifts_positions_without_new_rows_for_old_text( 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,14 +130,14 @@ 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 + workspace_id=db_workspace.id, source_markdown=_V1 ), ) ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)} @@ -145,7 +145,7 @@ async def test_removed_paragraph_is_deleted_and_order_compacts( 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 +162,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, ): @@ -172,7 +172,7 @@ async def test_kill_switch_falls_back_to_full_replace( document = await _index( service, make_connector_document( - search_space_id=db_search_space.id, source_markdown=_V1 + workspace_id=db_workspace.id, source_markdown=_V1 ), ) ids_v1 = {c.id for c in await _load_chunks(db_session, document.id)} @@ -181,7 +181,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..1a120843e 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,7 +14,7 @@ from app.db import ( DocumentType, DocumentVersion, Folder, - SearchSpace, + Workspace, User, ) @@ -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..be67a887b 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) @@ -60,32 +60,32 @@ async def test_legacy_composio_gmail_doc_migrated_in_db( async def test_no_legacy_doc_is_noop( - db_session, db_search_space, make_connector_document + 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..3f8914045 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,11 +222,11 @@ 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" + workspace_id=db_workspace.id, title="Old Title" ) service = IndexingPipelineService(session=db_session) @@ -239,7 +239,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" + workspace_id=db_workspace.id, title="New Title" ) await service.prepare_for_indexing([renamed]) @@ -252,11 +252,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 +269,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 +282,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 +302,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 +329,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 +337,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 +372,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 +386,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 +406,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 +416,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 +448,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..df73edf49 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 Workspace, User 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..004075975 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 Workspace, User 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,21 @@ 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 + 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..596c02943 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 Workspace, User 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..c5a9a9bd5 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 Workspace, User 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..1cc934a08 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 Workspace, User 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..b2995bcca 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 Workspace, User 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,21 @@ 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 + 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..a3e7a2c09 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 Workspace, User, 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..c8307d89e 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") @@ -39,12 +39,12 @@ async def test_cancel_from_a_terminal_state_conflicts( async def test_cancel_of_a_regeneration_is_rejected( - client, db_search_space, make_podcast + 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..a8dbc090f 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, Workspace, User 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..32a2b3e50 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, + Workspace, User, ) 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..b98f164fd 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, Workspace, User 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..ae9870ac7 100644 --- a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py +++ b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py @@ -32,7 +32,7 @@ from app.auth.context import AuthContext from app.db import ( SearchSourceConnector, SearchSourceConnectorType, - SearchSpace, + Workspace, User, ) from app.routes.obsidian_plugin_routes import ( @@ -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..b8cca33bf 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, Workspace, User 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..12c42098d 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, Workspace, User +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/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_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_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..bc59ee99e 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,20 +25,20 @@ 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): + async def _fake_setup(_session, *, workspace_id): return (SimpleNamespace(name="connector"), "fc-key") monkeypatch.setattr(deps_mod, "setup_connector_and_firecrawl", _fake_setup) @@ -48,35 +48,35 @@ def patched_side_effects(monkeypatch: pytest.MonkeyPatch): 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" @@ -84,7 +84,7 @@ async def test_build_dependencies_resolves_captured_chat_model_id( 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,18 @@ 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) + workspace = SimpleNamespace(chat_model_id=-7) result = await build_dependencies( - session=_FakeSession(search_space), search_space_id=42 + 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/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/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..44603dcf8 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", } @@ -37,7 +37,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 + p.document_type.value, p.unique_id, p.workspace_id ) @@ -82,7 +82,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_otel.py b/surfsense_backend/tests/unit/observability/test_otel.py index d3718e7b9..535f191fa 100644 --- a/surfsense_backend/tests/unit/observability/test_otel.py +++ b/surfsense_backend/tests/unit/observability/test_otel.py @@ -170,7 +170,7 @@ class TestMetricHelpers: metrics.record_tool_call_error(tool_name="web_search") 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"), 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/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/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_sort_task_dedupe.py b/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py index fd9018514..2acfde1ad 100644 --- a/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py +++ b/surfsense_backend/tests/unit/services/test_ai_sort_task_dedupe.py @@ -11,7 +11,7 @@ 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" + assert key == "ai_sort:workspace:42:lock" def test_lock_prevents_duplicate_run(): @@ -31,11 +31,11 @@ def test_lock_prevents_duplicate_run(): ): import asyncio - from app.tasks.celery_tasks.document_tasks import _ai_sort_search_space_async + from app.tasks.celery_tasks.document_tasks import _ai_sort_workspace_async loop = asyncio.new_event_loop() try: - loop.run_until_complete(_ai_sort_search_space_async(1, "user-123")) + loop.run_until_complete(_ai_sort_workspace_async(1, "user-123")) finally: loop.close() 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_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/tasks/chat/streaming/test_llm_bundle.py b/surfsense_backend/tests/unit/tasks/chat/streaming/test_llm_bundle.py index 7bb169496..bc3f0f7e5 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,12 @@ def _patch_common_bundle_dependencies(monkeypatch: pytest.MonkeyPatch): _CapturedChatLiteLLM.calls = [] - async def _fake_search_space( - _session: Any, _search_space_id: int + 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 +65,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 +83,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 +140,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/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..0b4f3aa9c 100644 --- a/surfsense_backend/tests/unit/test_pat_fail_closed_static.py +++ b/surfsense_backend/tests/unit/test_pat_fail_closed_static.py @@ -16,7 +16,7 @@ ALLOW_ANY_EXPECTED = { "routes/obsidian_plugin_routes.py": ( "_auth: AuthContext = Depends(allow_any_principal)" ), - "routes/search_spaces_routes.py": ( + "routes/workspaces_routes.py": ( "auth: AuthContext = Depends(allow_any_principal)" ), } @@ -60,12 +60,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/test_validators.py b/surfsense_backend/tests/unit/utils/test_validators.py index e0e7c6da8..7757bb157 100644 --- a/surfsense_backend/tests/unit/utils/test_validators.py +++ b/surfsense_backend/tests/unit/utils/test_validators.py @@ -11,7 +11,7 @@ from app.utils.validators import ( validate_messages, validate_research_mode, validate_search_mode, - validate_search_space_id, + validate_workspace_id, validate_top_k, validate_url, validate_uuid, @@ -34,8 +34,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 +54,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 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",