From 3b31352e1988354a86fbeb544f26bbae5a9bed0f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 11:35:30 +0200 Subject: [PATCH 01/22] fix: make migration 168 idempotent for from-scratch upgrades Migration 168 used bare add_column/drop_column, so `alembic upgrade head` from an empty DB failed at 168 ("column revoked_at already exists"): the 0_initial_schema migration bootstraps via Base.metadata.create_all (the live ORM shape), which already includes the hardened refresh_tokens columns, and 168's unguarded ALTERs then collided. Guard every step (ADD COLUMN IF NOT EXISTS, DROP COLUMN IF EXISTS, existence- checked backfill) so 168 no-ops on already-hardened (create_all) databases while still fully transforming legacy is_revoked-shape databases. Matches the guarded style already used by migrations 92/149/169. Net schema effect is unchanged. Restores a clean from-scratch `alembic upgrade head` to head. --- .../168_harden_refresh_token_schema.py | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) 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 fc14c8d73..4168da007 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" @@ -17,50 +15,52 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: - op.add_column( - "refresh_tokens", - sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True), + op.execute( + "ALTER TABLE refresh_tokens " + "ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMP WITH TIME ZONE" ) - op.add_column( - "refresh_tokens", - sa.Column("absolute_expiry", sa.TIMESTAMP(timezone=True), nullable=True), + op.execute( + "ALTER TABLE refresh_tokens " + "ADD COLUMN IF NOT EXISTS absolute_expiry TIMESTAMP WITH TIME ZONE" ) op.execute( """ - UPDATE refresh_tokens - SET revoked_at = NOW() - WHERE is_revoked = TRUE + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'refresh_tokens' + AND column_name = 'is_revoked' + ) THEN + UPDATE refresh_tokens SET revoked_at = NOW() WHERE is_revoked = TRUE; + END IF; + END $$; """ ) - op.alter_column( - "refresh_tokens", - "token_hash", - existing_type=sa.String(length=256), - type_=sa.String(length=64), - existing_nullable=False, - ) - op.drop_column("refresh_tokens", "is_revoked") + op.execute("ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(64)") + op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS is_revoked") def downgrade() -> None: - op.add_column( - "refresh_tokens", - sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="false"), + op.execute( + "ALTER TABLE refresh_tokens " + "ADD COLUMN IF NOT EXISTS is_revoked BOOLEAN NOT NULL DEFAULT false" ) 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.alter_column("refresh_tokens", "is_revoked", server_default=None) - op.alter_column( - "refresh_tokens", - "token_hash", - existing_type=sa.String(length=64), - type_=sa.String(length=256), - existing_nullable=False, - ) - op.drop_column("refresh_tokens", "absolute_expiry") - op.drop_column("refresh_tokens", "revoked_at") + op.execute("ALTER TABLE refresh_tokens ALTER COLUMN is_revoked DROP DEFAULT") + op.execute("ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(256)") + op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS absolute_expiry") + op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS revoked_at") From 533cdfcc81ced78712549ea36d5de6ffbafedc74 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 12:05:52 +0200 Subject: [PATCH 02/22] refactor(db): map search_space_id ORM attrs to physical workspace_id column Phase 1 (rename DB) commit 1a: shim every search_space_id / owner_search_space_id column to the renamed physical column (workspace_id / owner_workspace_id) via Column("workspace_id", ...), keeping the ORM attribute name unchanged so existing callers are untouched. Flip all Table-level references that resolve by column key (inner __table_args__ strings, the ck_connections_scope_owner CHECK text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named constraint/index names to workspace_id. Update the three raw-SQL test fixtures that referenced the physical column. Note: SQLAlchemy defaults a column's key to its name, so passing an explicit "workspace_id" name moves the Table.c key to workspace_id; Table-level string refs must therefore change in this phase (the opposite of the original plan's finding 1). The ORM attribute, the SearchSpace class, relationships, table names, and FK target strings are intentionally left for later commits/Phase 2. Verified: unit 2375 passed/1 skip, integration 346 passed (baseline parity); create_all builds every table with workspace_id. --- surfsense_backend/app/db.py | 100 +++++++++++++----- .../integration/document_upload/conftest.py | 2 +- .../test_obsidian_plugin_routes.py | 4 +- 3 files changed, 76 insertions(+), 30 deletions(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 2c9d28b58..163bce912 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -606,7 +606,10 @@ class NewChatThread(BaseModel, TimestampMixin): # Foreign keys search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) # Track who created this chat thread (for visibility filtering) @@ -786,7 +789,10 @@ class ExternalChatAccount(Base, TimestampMixin): 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", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=True, ) is_system_account = Column( Boolean, nullable=False, default=False, server_default="false" @@ -898,7 +904,10 @@ class ExternalChatBinding(Base, TimestampMixin): 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", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) state = Column( SQLAlchemyEnum( @@ -979,7 +988,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" ), ) @@ -1114,6 +1123,7 @@ class TokenUsage(BaseModel, TimestampMixin): index=True, ) search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False, @@ -1319,6 +1329,7 @@ class Folder(BaseModel, TimestampMixin): index=True, ) search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False, @@ -1383,7 +1394,10 @@ class Document(BaseModel, TimestampMixin): updated_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True) search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) folder_id = Column( @@ -1507,7 +1521,10 @@ class VideoPresentation(BaseModel, TimestampMixin): ) search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) search_space = relationship("SearchSpace", back_populates="video_presentations") @@ -1534,7 +1551,10 @@ class Report(BaseModel, TimestampMixin): ) # e.g. "executive_summary", "deep_research" search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) search_space = relationship("SearchSpace", back_populates="reports") @@ -1562,7 +1582,10 @@ class Connection(BaseModel, TimestampMixin): enabled = Column(Boolean, nullable=False, default=True, server_default="true") search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=True, ) user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True @@ -1580,8 +1603,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", ), @@ -1673,7 +1696,10 @@ class ImageGeneration(BaseModel, TimestampMixin): # Foreign keys search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) created_by_id = Column( UUID(as_uuid=True), @@ -1830,11 +1856,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( @@ -1882,7 +1908,10 @@ class SearchSourceConnector(BaseModel, TimestampMixin): next_scheduled_at = Column(TIMESTAMP(timezone=True), nullable=True) search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) search_space = relationship( "SearchSpace", back_populates="search_source_connectors" @@ -1909,7 +1938,10 @@ class Log(BaseModel, TimestampMixin): log_metadata = Column(JSON, nullable=True, default={}) # Additional context data search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) search_space = relationship("SearchSpace", back_populates="logs") @@ -2033,9 +2065,9 @@ class SearchSpaceRole(BaseModel, TimestampMixin): __tablename__ = "search_space_roles" __table_args__ = ( UniqueConstraint( - "search_space_id", + "workspace_id", "name", - name="uq_searchspace_role_name", + name="uq_workspace_role_name", ), ) @@ -2049,7 +2081,10 @@ class SearchSpaceRole(BaseModel, TimestampMixin): is_system_role = Column(Boolean, nullable=False, default=False) search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) search_space = relationship("SearchSpace", back_populates="roles") @@ -2071,8 +2106,8 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): __table_args__ = ( UniqueConstraint( "user_id", - "search_space_id", - name="uq_user_searchspace_membership", + "workspace_id", + name="uq_user_workspace_membership", ), ) @@ -2080,7 +2115,10 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): 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", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) role_id = Column( Integer, @@ -2122,7 +2160,10 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): 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", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) # Role to assign when invite is used (null means use default role) role_id = Column( @@ -2180,6 +2221,7 @@ class Prompt(BaseModel, TimestampMixin): index=True, ) search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True, @@ -2509,6 +2551,7 @@ class AgentActionLog(BaseModel): index=True, ) search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False, @@ -2580,6 +2623,7 @@ class DocumentRevision(BaseModel): index=True, ) search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False, @@ -2621,6 +2665,7 @@ class FolderRevision(BaseModel): index=True, ) search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False, @@ -2657,6 +2702,7 @@ class AgentPermissionRule(BaseModel): __tablename__ = "agent_permission_rules" search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False, @@ -2687,7 +2733,7 @@ class AgentPermissionRule(BaseModel): __table_args__ = ( UniqueConstraint( - "search_space_id", + "workspace_id", "user_id", "thread_id", "permission", @@ -2865,15 +2911,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)", ), ] diff --git a/surfsense_backend/tests/integration/document_upload/conftest.py b/surfsense_backend/tests/integration/document_upload/conftest.py index bd889360f..f894114d6 100644 --- a/surfsense_backend/tests/integration/document_upload/conftest.py +++ b/surfsense_backend/tests/integration/document_upload/conftest.py @@ -160,7 +160,7 @@ async def _purge_test_search_space(search_space_id: int): conn = await asyncpg.connect(_ASYNCPG_URL) try: result = await conn.execute( - "DELETE FROM documents WHERE search_space_id = $1", + "DELETE FROM documents WHERE workspace_id = $1", search_space_id, ) deleted = int(result.split()[-1]) diff --git a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py index d56c18420..0d874452a 100644 --- a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py +++ b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py @@ -125,11 +125,11 @@ 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 search_space_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 search_space_roles WHERE workspace_id = :id"), {"id": space_id}, ) await cleanup.execute( From e2578e09bcf3e790682c1b697e0c1be797101629 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 12:08:26 +0200 Subject: [PATCH 03/22] refactor(db): shim document_files.search_space_id to physical workspace_id Phase 1 (rename DB) commit 1b: same attribute->physical column shim as 1a, applied to the document_files satellite model. ORM attribute name unchanged; FK target string left for the searchspaces rename commit. --- surfsense_backend/app/file_storage/persistence/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_backend/app/file_storage/persistence/models.py b/surfsense_backend/app/file_storage/persistence/models.py index 7433f35ed..a3aed0205 100644 --- a/surfsense_backend/app/file_storage/persistence/models.py +++ b/surfsense_backend/app/file_storage/persistence/models.py @@ -30,6 +30,7 @@ class DocumentFile(BaseModel, TimestampMixin): index=True, ) search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False, From 9271678c73c9e72883b6c70af84f11ce887a9272 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 12:12:13 +0200 Subject: [PATCH 04/22] refactor(db): shim automations.search_space_id to physical workspace_id Phase 1 (rename DB) commit 1c: attribute->physical column shim on the automations satellite model. ORM attribute and relationship unchanged. --- .../app/automations/persistence/models/automation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_backend/app/automations/persistence/models/automation.py b/surfsense_backend/app/automations/persistence/models/automation.py index cb0b2ed31..f82e9c750 100644 --- a/surfsense_backend/app/automations/persistence/models/automation.py +++ b/surfsense_backend/app/automations/persistence/models/automation.py @@ -25,6 +25,7 @@ class Automation(BaseModel, TimestampMixin): __tablename__ = "automations" search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False, From bb953e6857611e7b9c8b744500010b6a76eaa8d6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 12:12:13 +0200 Subject: [PATCH 05/22] refactor(db): shim notifications.search_space_id to physical workspace_id Phase 1 (rename DB) commit 1d: attribute->physical column shim on the notifications satellite model, plus the inbox-list index column-ref and its name aligned to workspace (ix_notifications_user_workspace_created). --- surfsense_backend/app/notifications/persistence/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/notifications/persistence/models.py b/surfsense_backend/app/notifications/persistence/models.py index 557c4bf17..e0d3d8dfc 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", ), ) @@ -48,6 +48,7 @@ class Notification(BaseModel, TimestampMixin): index=True, ) search_space_id = Column( + "workspace_id", Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True, From 581ae693fc2901ec2a18d78311bb632893ea2d38 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 12:12:13 +0200 Subject: [PATCH 06/22] refactor(db): shim podcasts.search_space_id to physical workspace_id Phase 1 (rename DB) commit 1e: attribute->physical column shim on the podcasts satellite model. ORM attribute and relationship unchanged. --- surfsense_backend/app/podcasts/persistence/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/podcasts/persistence/models.py b/surfsense_backend/app/podcasts/persistence/models.py index 6e40a8040..0a93d31ed 100644 --- a/surfsense_backend/app/podcasts/persistence/models.py +++ b/surfsense_backend/app/podcasts/persistence/models.py @@ -69,7 +69,10 @@ class Podcast(BaseModel, TimestampMixin): file_location = Column(Text, nullable=True) search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + "workspace_id", + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, ) search_space = relationship("SearchSpace", back_populates="podcasts") From 3b545540e9dedf341e8312cc3bee281d291a828e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 12:29:29 +0200 Subject: [PATCH 07/22] refactor(db): rename search_space_invites table to workspace_invites Phase 1 (rename DB) commit 1f: rename the invites table and flip the one inbound FK (memberships.invited_by_invite_id). The invites row's own role_id -> search_space_roles FK is left for the roles rename commit. --- surfsense_backend/app/db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 163bce912..fff5c4d3a 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -2136,7 +2136,7 @@ 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, ) @@ -2154,7 +2154,7 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): 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) From 0ed043379d77bc817f34a18f6e0f8bd4ede3f13c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 12:30:38 +0200 Subject: [PATCH 08/22] refactor(db): rename search_space_memberships table to workspace_memberships Phase 1 (rename DB) commit 1g: rename the memberships table (no inbound FKs reference it) and update the matching raw-SQL cleanup in the obsidian plugin test. Relationship attribute names stay until Phase 2. --- surfsense_backend/app/db.py | 2 +- .../tests/integration/test_obsidian_plugin_routes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index fff5c4d3a..3a5843cd4 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -2102,7 +2102,7 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): Each user can be a member of multiple search spaces with different roles. """ - __tablename__ = "search_space_memberships" + __tablename__ = "workspace_memberships" __table_args__ = ( UniqueConstraint( "user_id", diff --git a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py index 0d874452a..1528f1d7b 100644 --- a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py +++ b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py @@ -125,7 +125,7 @@ async def race_user_and_space(async_engine): {"uid": user_id}, ) await cleanup.execute( - text("DELETE FROM search_space_memberships WHERE workspace_id = :id"), + text("DELETE FROM workspace_memberships WHERE workspace_id = :id"), {"id": space_id}, ) await cleanup.execute( From 35f80268df04e682cc44ffed683f56fe555370fc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 12:31:49 +0200 Subject: [PATCH 09/22] refactor(db): rename search_space_roles table to workspace_roles Phase 1 (rename DB) commit 1h: rename the roles table and flip both inbound FKs (memberships.role_id, invites.role_id), plus the matching raw-SQL cleanup in the obsidian plugin test. --- surfsense_backend/app/db.py | 6 +++--- .../tests/integration/test_obsidian_plugin_routes.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 3a5843cd4..60a243346 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -2062,7 +2062,7 @@ class SearchSpaceRole(BaseModel, TimestampMixin): Each search space can have multiple roles with different permission sets. """ - __tablename__ = "search_space_roles" + __tablename__ = "workspace_roles" __table_args__ = ( UniqueConstraint( "workspace_id", @@ -2122,7 +2122,7 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): ) 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 @@ -2168,7 +2168,7 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): # 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 diff --git a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py index 1528f1d7b..899359931 100644 --- a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py +++ b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py @@ -129,7 +129,7 @@ async def race_user_and_space(async_engine): {"id": space_id}, ) await cleanup.execute( - text("DELETE FROM search_space_roles WHERE workspace_id = :id"), + text("DELETE FROM workspace_roles WHERE workspace_id = :id"), {"id": space_id}, ) await cleanup.execute( From c6d4e5df7c84a3201b6b4fe3b7b20cfbc734f7eb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 13:27:16 +0200 Subject: [PATCH 10/22] refactor(db): rename searchspaces table to workspaces Phase 1 (rename DB) commit 1i: rename the SearchSpace table to workspaces and flip all 24 inbound FK target strings (20 in db.py + the 4 satellite models), plus the raw-SQL searchspaces references in the obsidian and google-unification integration fixtures. This completes the ORM half of Phase 1. The SearchSpace class name, relationship/back_populates attribute names, and the /searchspaces API route URLs are intentionally left for Phase 2. Verified: unit 2375 passed/1 skip, integration 346 passed (baseline parity); create_all builds every table with the workspaces name and all FKs resolving to workspaces.id. --- .../persistence/models/automation.py | 2 +- surfsense_backend/app/db.py | 42 +++++++++---------- .../app/file_storage/persistence/models.py | 2 +- .../app/notifications/persistence/models.py | 2 +- .../app/podcasts/persistence/models.py | 2 +- .../google_unification/conftest.py | 4 +- .../test_obsidian_plugin_routes.py | 2 +- 7 files changed, 28 insertions(+), 28 deletions(-) diff --git a/surfsense_backend/app/automations/persistence/models/automation.py b/surfsense_backend/app/automations/persistence/models/automation.py index f82e9c750..76ba5e2e6 100644 --- a/surfsense_backend/app/automations/persistence/models/automation.py +++ b/surfsense_backend/app/automations/persistence/models/automation.py @@ -27,7 +27,7 @@ class Automation(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 60a243346..28e0f159a 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -608,7 +608,7 @@ class NewChatThread(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) @@ -791,7 +791,7 @@ class ExternalChatAccount(Base, TimestampMixin): owner_search_space_id = Column( "owner_workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, ) is_system_account = Column( @@ -906,7 +906,7 @@ class ExternalChatBinding(Base, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) state = Column( @@ -1125,7 +1125,7 @@ class TokenUsage(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -1331,7 +1331,7 @@ class Folder(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -1396,7 +1396,7 @@ class Document(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) @@ -1523,7 +1523,7 @@ class VideoPresentation(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) search_space = relationship("SearchSpace", back_populates="video_presentations") @@ -1553,7 +1553,7 @@ class Report(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) search_space = relationship("SearchSpace", back_populates="reports") @@ -1584,7 +1584,7 @@ class Connection(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, ) user_id = Column( @@ -1698,7 +1698,7 @@ class ImageGeneration(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) created_by_id = Column( @@ -1714,7 +1714,7 @@ class ImageGeneration(BaseModel, TimestampMixin): class SearchSpace(BaseModel, TimestampMixin): - __tablename__ = "searchspaces" + __tablename__ = "workspaces" name = Column(String(100), nullable=False, index=True) description = Column(String(500), nullable=True) @@ -1910,7 +1910,7 @@ class SearchSourceConnector(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) search_space = relationship( @@ -1940,7 +1940,7 @@ class Log(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) search_space = relationship("SearchSpace", back_populates="logs") @@ -2083,7 +2083,7 @@ class SearchSpaceRole(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) search_space = relationship("SearchSpace", back_populates="roles") @@ -2117,7 +2117,7 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) role_id = Column( @@ -2162,7 +2162,7 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) # Role to assign when invite is used (null means use default role) @@ -2223,7 +2223,7 @@ class Prompt(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, index=True, ) @@ -2553,7 +2553,7 @@ class AgentActionLog(BaseModel): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -2625,7 +2625,7 @@ class DocumentRevision(BaseModel): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -2667,7 +2667,7 @@ class FolderRevision(BaseModel): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -2704,7 +2704,7 @@ class AgentPermissionRule(BaseModel): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) diff --git a/surfsense_backend/app/file_storage/persistence/models.py b/surfsense_backend/app/file_storage/persistence/models.py index a3aed0205..bc6cef07f 100644 --- a/surfsense_backend/app/file_storage/persistence/models.py +++ b/surfsense_backend/app/file_storage/persistence/models.py @@ -32,7 +32,7 @@ class DocumentFile(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, ) diff --git a/surfsense_backend/app/notifications/persistence/models.py b/surfsense_backend/app/notifications/persistence/models.py index e0d3d8dfc..d75a9d8dc 100644 --- a/surfsense_backend/app/notifications/persistence/models.py +++ b/surfsense_backend/app/notifications/persistence/models.py @@ -50,7 +50,7 @@ class Notification(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, index=True, ) diff --git a/surfsense_backend/app/podcasts/persistence/models.py b/surfsense_backend/app/podcasts/persistence/models.py index 0a93d31ed..559998d4e 100644 --- a/surfsense_backend/app/podcasts/persistence/models.py +++ b/surfsense_backend/app/podcasts/persistence/models.py @@ -71,7 +71,7 @@ class Podcast(BaseModel, TimestampMixin): search_space_id = Column( "workspace_id", Integer, - ForeignKey("searchspaces.id", ondelete="CASCADE"), + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, ) search_space = relationship("SearchSpace", back_populates="podcasts") diff --git a/surfsense_backend/tests/integration/google_unification/conftest.py b/surfsense_backend/tests/integration/google_unification/conftest.py index 151ee98e3..e096b30d6 100644 --- a/surfsense_backend/tests/integration/google_unification/conftest.py +++ b/surfsense_backend/tests/integration/google_unification/conftest.py @@ -199,7 +199,7 @@ async def committed_google_data(async_engine): 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} ) @@ -306,5 +306,5 @@ async def cleanup_space(async_engine, space_id: int): """Delete a search space (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/test_obsidian_plugin_routes.py b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py index 899359931..576255fe0 100644 --- a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py +++ b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py @@ -133,7 +133,7 @@ async def race_user_and_space(async_engine): {"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( From 464e28ea25f33ad41c0ccfe5dd5e873d883dbbaa Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 16:56:39 +0200 Subject: [PATCH 11/22] refactor(db): publish workspace_id column in zero_publication Phase 1 (rename DB) commit 2: flip the search_space_id entries in the canonical Zero publication column lists (documents, automations, new_chat_threads, podcasts) to the renamed physical column workspace_id, so ALTER PUBLICATION ... SET TABLE matches the post-rename schema. No test references the publication shape; functional verification via `python -m app.zero_publication --verify` runs after migration 170 builds/renames the publication against a live DB. --- surfsense_backend/app/zero_publication.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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", ] From 5090ed734d837db4087d993eb4da93c1c7d5ab8e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 17:27:31 +0200 Subject: [PATCH 12/22] feat(db): rename searchspace schema to workspace (migration 170) Phase 1 (rename DB) commit 3: single Alembic migration (down_revision=169) that physically renames the SearchSpace schema to WorkSpace to match the already-shimmed ORM. Renames 4 tables, 24 columns (search_space_id->workspace_id, owner_search_space_id->owner_workspace_id), 37 named/auto FK/PK/unique constraints, 25 indexes, and 4 sequences. Guarded forms (ALTER ... IF EXISTS / DO-block RENAME CONSTRAINT) tolerate runtime-created and absent objects. Zero publication is reconciled via the blessed path: neutralize the four column-list tables (documents/automations/new_chat_threads/podcasts) from the publication, rename, then apply_publication() re-emits SET TABLE with the new workspace_id lists (no raw DROP/CREATE PUBLICATION, per migration 116). Downgrade fully reverses the renames and restores the old search_space_id publication shape via a hardcoded SET TABLE, never importing the live module. Targets the existing-deployment upgrade path (169 -> 170). The from-scratch alembic path is a separate, pre-existing concern (0_initial uses create_all). Verified on a realistic legacy@169 DB (with publication): upgrade clean, zero_publication --verify passes, autogenerate EMPTY on a create_all DB, and upgrade->downgrade->upgrade round-trips (old shape + old publication restored on downgrade). Single alembic head = 170. --- .../170_rename_searchspace_to_workspace.py | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py 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..dc2d75830 --- /dev/null +++ b/surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py @@ -0,0 +1,514 @@ +"""rename searchspace schema to workspace + +Physically renames the SearchSpace schema to WorkSpace: tables, the +``search_space_id`` / ``owner_search_space_id`` columns, the named +constraints/indexes/sequences that embed the old name, and the auto-named +FK/PK/sequence objects. The Zero publication is reconciled to the renamed +``workspace_id`` column lists via the blessed ``apply_publication`` path +(never raw DROP/CREATE PUBLICATION -- see migration 116). + +This is the existing-deployment upgrade path (chains after head 169). The +from-scratch ``alembic upgrade head`` path is a separate, pre-existing concern +(0_initial uses create_all of the current ORM); it is out of scope here. + +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 (blessed SET TABLE path); 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) From 49d3001fbf4a2995ffdb96706de42d81f935662e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 17:59:05 +0200 Subject: [PATCH 13/22] docs(db): tighten migration 170 docstring, move publication invariant to call site Trim the header to a one-line summary and relocate the "never raw DROP/CREATE PUBLICATION" invariant (bug #1355, migration 116) to the apply_publication call site where a refactor would actually occur. Comment/docstring only; no behavior change. --- .../170_rename_searchspace_to_workspace.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py b/surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py index dc2d75830..2815f9603 100644 --- a/surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py +++ b/surfsense_backend/alembic/versions/170_rename_searchspace_to_workspace.py @@ -1,15 +1,8 @@ """rename searchspace schema to workspace -Physically renames the SearchSpace schema to WorkSpace: tables, the -``search_space_id`` / ``owner_search_space_id`` columns, the named -constraints/indexes/sequences that embed the old name, and the auto-named -FK/PK/sequence objects. The Zero publication is reconciled to the renamed -``workspace_id`` column lists via the blessed ``apply_publication`` path -(never raw DROP/CREATE PUBLICATION -- see migration 116). - -This is the existing-deployment upgrade path (chains after head 169). The -from-scratch ``alembic upgrade head`` path is a separate, pre-existing concern -(0_initial uses create_all of the current ORM); it is out of scope here. +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 @@ -488,8 +481,9 @@ def upgrade() -> None: for old, new in SEQUENCE_RENAMES: _rename_sequence(conn, old, new) - # Reconcile to the new workspace_id shape (blessed SET TABLE path); no-op - # if the publication does not exist. + # 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) From df03565940165d81e0f670d0b28b842bd8deafee Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 18:18:50 +0200 Subject: [PATCH 14/22] refactor(db): rename SearchSpace ORM core to Workspace (Phase 2 Wave A) Flip the symbolic name in db.py: classes SearchSpace/Role/Membership/Invite -> Workspace*, attributes search_space_id -> workspace_id (and owner_*), relationship attrs + back_populates pairs, and drop the Phase 1 Column("workspace_id", ...) shim now that attribute name == column name. Enum values 'SEARCH_SPACE' and the SearchSourceConnector class are intentionally untouched (carve-outs). Part of the atomic SearchSpace -> Workspace rename; the suite goes green only once Waves B-F land (no half-renamed steady state). --- surfsense_backend/app/db.py | 214 ++++++++++++++++-------------------- 1 file changed, 97 insertions(+), 117 deletions(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 28e0f159a..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,7 @@ class NewChatThread(BaseModel, TimestampMixin): ) # Foreign keys - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -647,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) @@ -664,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", @@ -788,8 +787,7 @@ class ExternalChatAccount(Base, TimestampMixin): owner_user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True ) - owner_search_space_id = Column( - "owner_workspace_id", + owner_workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, @@ -825,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", @@ -903,8 +901,7 @@ class ExternalChatBinding(Base, TimestampMixin): user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -957,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", @@ -1122,8 +1119,7 @@ class TokenUsage(BaseModel, TimestampMixin): nullable=True, index=True, ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -1139,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") @@ -1328,8 +1324,7 @@ class Folder(BaseModel, TimestampMixin): nullable=True, index=True, ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -1351,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) @@ -1368,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. @@ -1393,8 +1388,7 @@ 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( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -1435,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") @@ -1520,13 +1514,12 @@ class VideoPresentation(BaseModel, TimestampMixin): index=True, ) - search_space_id = Column( - "workspace_id", + 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, @@ -1550,13 +1543,12 @@ class Report(BaseModel, TimestampMixin): String(100), nullable=True ) # e.g. "executive_summary", "deep_research" - search_space_id = Column( - "workspace_id", + 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). @@ -1581,8 +1573,7 @@ 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( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, @@ -1591,7 +1582,7 @@ class Connection(BaseModel, TimestampMixin): 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", @@ -1695,8 +1686,7 @@ class ImageGeneration(BaseModel, TimestampMixin): access_token = Column(String(64), nullable=True, index=True) # Foreign keys - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -1709,11 +1699,11 @@ 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): +class Workspace(BaseModel, TimestampMixin): __tablename__ = "workspaces" name = Column(String(100), nullable=False, index=True) @@ -1735,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 @@ -1753,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, @@ -1825,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, @@ -1833,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", ) @@ -1907,14 +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( - "workspace_id", + 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( @@ -1937,13 +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( - "workspace_id", + 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): @@ -2056,10 +2044,10 @@ 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__ = "workspace_roles" @@ -2080,26 +2068,25 @@ 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( - "workspace_id", + 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__ = "workspace_memberships" @@ -2114,8 +2101,7 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -2125,7 +2111,7 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): 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( @@ -2140,17 +2126,17 @@ class SearchSpaceMembership(BaseModel, TimestampMixin): 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. """ @@ -2159,8 +2145,7 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): # Unique invite code (used in invite URLs) invite_code = Column(String(64), nullable=False, unique=True, index=True) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -2189,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, ) @@ -2220,8 +2205,7 @@ class Prompt(BaseModel, TimestampMixin): nullable=False, index=True, ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, @@ -2238,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": @@ -2250,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", @@ -2259,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, ) @@ -2387,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", @@ -2396,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, ) @@ -2550,8 +2534,7 @@ class AgentActionLog(BaseModel): nullable=True, index=True, ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -2622,8 +2605,7 @@ class DocumentRevision(BaseModel): nullable=True, index=True, ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -2664,8 +2646,7 @@ class FolderRevision(BaseModel): nullable=True, index=True, ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -2693,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. @@ -2701,8 +2682,7 @@ class AgentPermissionRule(BaseModel): __tablename__ = "agent_permission_rules" - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -3080,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) @@ -3093,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, @@ -3107,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, From d948b769ba8e205fd8816f2221ed224a8fcd3329 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 18:20:28 +0200 Subject: [PATCH 15/22] refactor(db): rename search_space to workspace in satellite ORM models (Phase 2 Wave B) Flip search_space_id -> workspace_id and the workspace relationship/back_populates in the automations, file_storage, notifications, and podcasts persistence models, and drop the Phase 1 Column("workspace_id", ...) shim. Full SQLAlchemy mapper configuration now passes (db.py + satellites consistent). --- .../app/automations/persistence/models/automation.py | 5 ++--- surfsense_backend/app/file_storage/persistence/models.py | 3 +-- surfsense_backend/app/notifications/persistence/models.py | 5 ++--- surfsense_backend/app/podcasts/persistence/models.py | 5 ++--- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/surfsense_backend/app/automations/persistence/models/automation.py b/surfsense_backend/app/automations/persistence/models/automation.py index 76ba5e2e6..486a1ecea 100644 --- a/surfsense_backend/app/automations/persistence/models/automation.py +++ b/surfsense_backend/app/automations/persistence/models/automation.py @@ -24,8 +24,7 @@ from ..enums.automation_status import AutomationStatus class Automation(BaseModel, TimestampMixin): __tablename__ = "automations" - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, @@ -66,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/file_storage/persistence/models.py b/surfsense_backend/app/file_storage/persistence/models.py index bc6cef07f..e16774f12 100644 --- a/surfsense_backend/app/file_storage/persistence/models.py +++ b/surfsense_backend/app/file_storage/persistence/models.py @@ -29,8 +29,7 @@ class DocumentFile(BaseModel, TimestampMixin): nullable=False, index=True, ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, diff --git a/surfsense_backend/app/notifications/persistence/models.py b/surfsense_backend/app/notifications/persistence/models.py index d75a9d8dc..ac5028207 100644 --- a/surfsense_backend/app/notifications/persistence/models.py +++ b/surfsense_backend/app/notifications/persistence/models.py @@ -47,8 +47,7 @@ class Notification(BaseModel, TimestampMixin): nullable=False, index=True, ) - search_space_id = Column( - "workspace_id", + workspace_id = Column( Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True, @@ -70,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/podcasts/persistence/models.py b/surfsense_backend/app/podcasts/persistence/models.py index 559998d4e..2c7e2a6c2 100644 --- a/surfsense_backend/app/podcasts/persistence/models.py +++ b/surfsense_backend/app/podcasts/persistence/models.py @@ -68,13 +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( - "workspace_id", + 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, From 0c53d884eba74b8e632698f5fe58d4bc7231df66 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 18:23:43 +0200 Subject: [PATCH 16/22] refactor(schemas): rename SearchSpace -> Workspace in Pydantic schemas (Phase 2 Wave C) Rename schemas/search_space.py -> schemas/workspace.py, the SearchSpace* classes -> Workspace* (incl. UserSearchSpaceAccess -> UserWorkspaceAccess), and the search_space_id / search_space_name fields -> workspace_id / workspace_name across all schema modules. Under hard cutover the serialized JSON keys change outright (no alias). Re-exports in schemas/__init__.py updated. --- surfsense_backend/app/schemas/__init__.py | 32 +++++++++---------- .../app/schemas/chat_comments.py | 4 +-- surfsense_backend/app/schemas/documents.py | 4 +-- surfsense_backend/app/schemas/folders.py | 4 +-- .../app/schemas/image_generation.py | 12 +++---- surfsense_backend/app/schemas/logs.py | 6 ++-- .../app/schemas/model_connections.py | 4 +-- surfsense_backend/app/schemas/new_chat.py | 16 +++++----- .../app/schemas/obsidian_plugin.py | 4 +-- surfsense_backend/app/schemas/prompts.py | 4 +-- surfsense_backend/app/schemas/rbac_schemas.py | 20 ++++++------ surfsense_backend/app/schemas/reports.py | 2 +- .../app/schemas/search_source_connector.py | 6 ++-- surfsense_backend/app/schemas/stripe.py | 4 +-- .../app/schemas/video_presentations.py | 4 +-- .../schemas/{search_space.py => workspace.py} | 14 ++++---- 16 files changed, 70 insertions(+), 70 deletions(-) rename surfsense_backend/app/schemas/{search_space.py => workspace.py} (71%) 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 From 7fb07079333fd318df9afbaef76f6694402a6620 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 18:30:47 +0200 Subject: [PATCH 17/22] refactor(backend): rename search_space -> workspace across app bulk (Phase 2 Wave D) Scoped codemod over surfsense_backend/app (excluding routes/, Wave E): renames search_space_id -> workspace_id, search_space -> workspace, SearchSpace -> Workspace across services, utils, tasks, agents, gateway, event_bus, notifications, podcasts, automations, observability params, and prompt .md files. Also flips the camelCase payload key searchSpaceId -> workspaceId (no backend reader; hard cutover). Preserved carve-outs (verbatim): Celery task names "delete_search_space_background" and "ai_sort_search_space" (wire names), and the OTel/metric key "search_space.id" (dashboards depend on it). Enum values 'SEARCH_SPACE' and SearchSourceConnector untouched. --- .../app/agents/chat/anonymous_chat/agent.py | 2 +- .../main_agent/graph/compile_graph_sync.py | 4 +- .../middleware/action_log/builder.py | 4 +- .../middleware/action_log/middleware.py | 8 +- .../middleware.py | 10 +- .../spawn_paused.py | 22 +- .../task_tool.py | 8 +- .../middleware/kb_persistence/builder.py | 4 +- .../middleware/kb_persistence/middleware.py | 120 ++++---- .../middleware/knowledge_tree/builder.py | 4 +- .../middleware/knowledge_tree/middleware.py | 16 +- .../main_agent/middleware/memory/builder.py | 4 +- .../middleware/memory/middleware.py | 10 +- .../main_agent/middleware/plugins.py | 4 +- .../main_agent/middleware/skills.py | 4 +- .../main_agent/middleware/stack.py | 16 +- .../main_agent/plugins/loader.py | 6 +- .../main_agent/plugins/year_substituter.py | 2 +- .../main_agent/runtime/agent_cache.py | 8 +- .../main_agent/runtime/agent_cache_store.py | 6 +- .../runtime/connector_searchable_types.py | 2 +- .../main_agent/runtime/factory.py | 20 +- .../main_agent/skills/backends.py | 32 +-- .../tools/update_memory/team/description.md | 2 +- .../main_agent/tools/automation/create.py | 22 +- .../main_agent/tools/automation/prompt.py | 6 +- .../main_agent/tools/registry.py | 10 +- .../main_agent/tools/update_memory.py | 6 +- .../filesystem/backends/kb_postgres.py | 22 +- .../filesystem/backends/resolver.py | 8 +- .../shared/middleware/filesystem/index.py | 4 +- .../filesystem/middleware/middleware.py | 4 +- .../shared/retrieval/hybrid_search.py | 18 +- .../shared/retrieval/service.py | 4 +- .../multi_agent_chat/shared/tools/hitl.py | 2 +- .../shared/tools/mcp/cache.py | 8 +- .../multi_agent_chat/shared/tools/mcp/tool.py | 28 +- .../deliverables/tools/generate_image.py | 38 +-- .../builtins/deliverables/tools/index.py | 10 +- .../builtins/deliverables/tools/podcast.py | 8 +- .../builtins/deliverables/tools/report.py | 12 +- .../builtins/deliverables/tools/resume.py | 8 +- .../deliverables/tools/video_presentation.py | 8 +- .../builtins/knowledge_base/agent.py | 2 +- .../knowledge_base/middleware_stack.py | 2 +- .../tools/search_knowledge_base.py | 12 +- .../builtins/memory/system_prompt.md | 2 +- .../subagents/builtins/memory/tools/index.py | 2 +- .../builtins/memory/tools/update_memory.py | 6 +- .../builtins/research/tools/index.py | 2 +- .../connectors/calendar/tools/create_event.py | 12 +- .../connectors/calendar/tools/delete_event.py | 8 +- .../connectors/calendar/tools/index.py | 2 +- .../calendar/tools/search_events.py | 6 +- .../connectors/calendar/tools/update_event.py | 10 +- .../confluence/tools/create_page.py | 12 +- .../confluence/tools/delete_page.py | 8 +- .../connectors/confluence/tools/index.py | 2 +- .../confluence/tools/update_page.py | 10 +- .../connectors/discord/tools/_auth.py | 4 +- .../connectors/discord/tools/index.py | 2 +- .../connectors/discord/tools/list_channels.py | 6 +- .../connectors/discord/tools/read_messages.py | 6 +- .../connectors/discord/tools/send_message.py | 6 +- .../connectors/dropbox/tools/create_file.py | 10 +- .../connectors/dropbox/tools/index.py | 2 +- .../connectors/dropbox/tools/trash_file.py | 12 +- .../connectors/gmail/tools/create_draft.py | 12 +- .../subagents/connectors/gmail/tools/index.py | 2 +- .../connectors/gmail/tools/read_email.py | 6 +- .../connectors/gmail/tools/search_emails.py | 6 +- .../connectors/gmail/tools/send_email.py | 12 +- .../connectors/gmail/tools/trash_email.py | 8 +- .../connectors/gmail/tools/update_draft.py | 8 +- .../google_drive/tools/create_file.py | 12 +- .../connectors/google_drive/tools/index.py | 2 +- .../google_drive/tools/trash_file.py | 8 +- .../subagents/connectors/luma/tools/_auth.py | 4 +- .../connectors/luma/tools/create_event.py | 6 +- .../subagents/connectors/luma/tools/index.py | 2 +- .../connectors/luma/tools/list_events.py | 6 +- .../connectors/luma/tools/read_event.py | 6 +- .../connectors/notion/tools/create_page.py | 18 +- .../connectors/notion/tools/delete_page.py | 12 +- .../connectors/notion/tools/index.py | 2 +- .../connectors/notion/tools/update_page.py | 14 +- .../connectors/onedrive/tools/create_file.py | 10 +- .../connectors/onedrive/tools/index.py | 2 +- .../connectors/onedrive/tools/trash_file.py | 12 +- .../subagents/connectors/teams/tools/_auth.py | 4 +- .../subagents/connectors/teams/tools/index.py | 2 +- .../connectors/teams/tools/list_channels.py | 6 +- .../connectors/teams/tools/read_messages.py | 6 +- .../connectors/teams/tools/send_message.py | 6 +- .../subagents/mcp_tools/index.py | 10 +- .../approvals/self_gated/auto_approved.py | 2 +- .../multi_agent_chat/subagents/shared/spec.py | 4 +- .../agents/chat/runtime/mention_resolver.py | 14 +- .../app/agents/chat/runtime/path_resolver.py | 32 +-- .../referenced_chat_context/resolver.py | 18 +- .../chat/runtime/references/__init__.py | 12 +- .../chat/runtime/references/chat/access.py | 14 +- .../chat/runtime/references/chat/resolver.py | 4 +- .../chat/runtime/references/connectors.py | 6 +- .../references/documents/referenced.py | 6 +- .../runtime/references/documents/resolver.py | 6 +- .../agents/chat/runtime/references/folders.py | 4 +- .../app/agents/chat/shared/context.py | 4 +- .../agents/chat/shared/tools/web_search.py | 14 +- .../video_presentation/configuration.py | 2 +- .../app/agents/video_presentation/nodes.py | 18 +- surfsense_backend/app/app.py | 2 +- .../builtin/agent_task/dependencies.py | 22 +- .../actions/builtin/agent_task/invoke.py | 14 +- .../app/automations/actions/types.py | 6 +- .../app/automations/api/automation.py | 12 +- .../app/automations/runtime/executor.py | 4 +- .../app/automations/schemas/api/automation.py | 4 +- .../schemas/definition/envelope.py | 6 +- .../app/automations/services/automation.py | 70 ++--- .../app/automations/services/model_policy.py | 22 +- .../app/automations/services/run.py | 4 +- .../app/automations/services/trigger.py | 4 +- .../app/automations/templating/context.py | 4 +- surfsense_backend/app/config/__init__.py | 4 +- .../google_drive/content_extractor.py | 6 +- surfsense_backend/app/event_bus/__init__.py | 2 +- surfsense_backend/app/event_bus/bus.py | 4 +- surfsense_backend/app/event_bus/event.py | 4 +- .../events/document_entered_folder.py | 4 +- surfsense_backend/app/file_storage/api.py | 4 +- surfsense_backend/app/file_storage/keys.py | 6 +- surfsense_backend/app/file_storage/service.py | 6 +- surfsense_backend/app/gateway/agent_invoke.py | 2 +- .../app/gateway/auth_invariant.py | 8 +- surfsense_backend/app/gateway/bindings.py | 2 +- .../app/gateway/inbox_processor.py | 8 +- .../adapters/file_upload_adapter.py | 6 +- .../indexing_pipeline/connector_document.py | 2 +- .../app/indexing_pipeline/document_hashing.py | 10 +- .../indexing_pipeline_service.py | 28 +- .../app/indexing_pipeline/pipeline_logger.py | 4 +- .../app/notifications/api/api.py | 44 +-- .../app/notifications/api/schemas.py | 2 +- .../app/notifications/api/transform.py | 2 +- .../app/notifications/service/base.py | 12 +- .../app/notifications/service/facade.py | 4 +- .../service/handlers/auto_reload_failed.py | 4 +- .../service/handlers/comment_reply.py | 4 +- .../service/handlers/connector_indexing.py | 8 +- .../service/handlers/document_processing.py | 6 +- .../service/handlers/insufficient_credits.py | 8 +- .../notifications/service/handlers/mention.py | 4 +- .../service/messages/document_processing.py | 4 +- .../service/messages/insufficient_credits.py | 4 +- .../app/observability/metrics.py | 4 +- surfsense_backend/app/observability/otel.py | 12 +- surfsense_backend/app/podcasts/api/routes.py | 34 +-- surfsense_backend/app/podcasts/api/schemas.py | 8 +- .../app/podcasts/generation/brief/propose.py | 4 +- .../podcasts/generation/transcript/config.py | 2 +- .../podcasts/generation/transcript/nodes.py | 4 +- .../app/podcasts/persistence/repository.py | 4 +- surfsense_backend/app/podcasts/service.py | 4 +- surfsense_backend/app/podcasts/storage.py | 10 +- surfsense_backend/app/podcasts/tasks/draft.py | 16 +- .../app/podcasts/tasks/render.py | 2 +- .../app/retriever/chunks_hybrid_search.py | 50 ++-- .../app/retriever/documents_hybrid_search.py | 50 ++-- .../app/services/ai_file_sort_service.py | 14 +- .../app/services/auto_model_pin_service.py | 42 +-- .../app/services/billable_calls.py | 50 ++-- .../app/services/chat_comments_service.py | 90 +++--- .../services/confluence/kb_sync_service.py | 12 +- .../confluence/tool_metadata_service.py | 20 +- .../app/services/connector_service.py | 268 +++++++++--------- .../app/services/dropbox/kb_sync_service.py | 8 +- .../app/services/export_service.py | 8 +- .../app/services/folder_service.py | 12 +- .../app/services/gmail/kb_sync_service.py | 8 +- .../services/gmail/tool_metadata_service.py | 20 +- .../google_calendar/kb_sync_service.py | 12 +- .../google_calendar/tool_metadata_service.py | 26 +- .../services/google_drive/kb_sync_service.py | 8 +- .../google_drive/tool_metadata_service.py | 12 +- .../app/services/linear/kb_sync_service.py | 14 +- .../services/linear/tool_metadata_service.py | 22 +- surfsense_backend/app/services/llm_service.py | 90 +++--- .../app/services/memory/service.py | 12 +- .../app/services/notion/kb_sync_service.py | 12 +- .../services/notion/tool_metadata_service.py | 22 +- .../app/services/obsidian_plugin_indexer.py | 34 +-- .../app/services/onedrive/kb_sync_service.py | 8 +- .../app/services/public_chat_service.py | 46 +-- .../app/services/quota_checked_vision_llm.py | 8 +- .../app/services/revert_service.py | 20 +- .../app/services/task_dispatcher.py | 6 +- .../app/services/task_logging_service.py | 8 +- .../app/services/token_tracking_service.py | 4 +- .../app/services/user_tool_allowlist.py | 6 +- surfsense_backend/app/session_events.py | 4 +- .../app/tasks/celery_tasks/connector_tasks.py | 98 +++---- .../celery_tasks/document_reindex_tasks.py | 2 +- .../app/tasks/celery_tasks/document_tasks.py | 162 +++++------ .../celery_tasks/schedule_checker_task.py | 6 +- .../celery_tasks/video_presentation_tasks.py | 20 +- .../app/tasks/chat/persistence.py | 4 +- .../app/tasks/chat/streaming/agent/builder.py | 4 +- .../tasks/chat/streaming/agent/event_loop.py | 6 +- .../tasks/chat/streaming/errors/classifier.py | 4 +- .../tasks/chat/streaming/errors/emitter.py | 4 +- .../chat/streaming/flows/new_chat/auto_pin.py | 6 +- .../streaming/flows/new_chat/input_state.py | 12 +- .../streaming/flows/new_chat/orchestrator.py | 38 +-- .../flows/new_chat/runtime_context.py | 4 +- .../flows/resume_chat/orchestrator.py | 36 +-- .../flows/resume_chat/runtime_context.py | 4 +- .../flows/shared/assistant_finalize.py | 4 +- .../chat/streaming/flows/shared/llm_bundle.py | 26 +- .../flows/shared/pre_stream_setup.py | 6 +- .../flows/shared/rate_limit_recovery.py | 8 +- .../tasks/chat/streaming/flows/shared/span.py | 4 +- .../streaming/flows/shared/stream_loop.py | 4 +- .../streaming/flows/shared/terminal_error.py | 4 +- .../app/tasks/composio_indexer.py | 8 +- .../connector_indexers/airtable_indexer.py | 12 +- .../app/tasks/connector_indexers/base.py | 4 +- .../connector_indexers/bookstack_indexer.py | 12 +- .../connector_indexers/clickup_indexer.py | 12 +- .../connector_indexers/confluence_indexer.py | 14 +- .../connector_indexers/discord_indexer.py | 12 +- .../connector_indexers/dropbox_indexer.py | 58 ++-- .../elasticsearch_indexer.py | 12 +- .../connector_indexers/github_indexer.py | 12 +- .../google_calendar_indexer.py | 16 +- .../google_drive_indexer.py | 96 +++---- .../google_gmail_indexer.py | 16 +- .../connector_indexers/linear_indexer.py | 14 +- .../local_folder_indexer.py | 98 +++---- .../tasks/connector_indexers/luma_indexer.py | 12 +- .../connector_indexers/notion_indexer.py | 14 +- .../connector_indexers/onedrive_indexer.py | 60 ++-- .../tasks/connector_indexers/slack_indexer.py | 12 +- .../tasks/connector_indexers/teams_indexer.py | 12 +- .../connector_indexers/webcrawler_indexer.py | 20 +- .../app/tasks/document_processors/_helpers.py | 8 +- .../app/tasks/document_processors/_save.py | 10 +- .../circleback_processor.py | 32 +-- .../extension_processor.py | 12 +- .../document_processors/file_processors.py | 26 +- .../document_processors/markdown_processor.py | 12 +- .../document_processors/youtube_processor.py | 12 +- surfsense_backend/app/users.py | 36 +-- .../app/utils/connector_naming.py | 28 +- .../app/utils/document_converters.py | 16 +- surfsense_backend/app/utils/oauth_security.py | 2 +- .../app/utils/periodic_scheduler.py | 12 +- surfsense_backend/app/utils/rbac.py | 180 ++++++------ surfsense_backend/app/utils/validators.py | 40 +-- 259 files changed, 1996 insertions(+), 1996 deletions(-) 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/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 47e529741..c4ee33c30 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/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/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/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/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/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 3b9616cbd..e30666a20 100644 --- a/surfsense_backend/app/tasks/document_processors/_save.py +++ b/surfsense_backend/app/tasks/document_processors/_save.py @@ -28,7 +28,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, @@ -43,7 +43,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 @@ -53,9 +53,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 @@ -99,7 +99,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 19a4df87d..9e31327d9 100644 --- a/surfsense_backend/app/tasks/document_processors/markdown_processor.py +++ b/surfsense_backend/app/tasks/document_processors/markdown_processor.py @@ -120,7 +120,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: @@ -131,14 +131,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( @@ -155,11 +155,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( @@ -214,7 +214,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", ) From 56826a63bcd2ae5482f8b5c7b61e38ed4d998208 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 18:35:08 +0200 Subject: [PATCH 18/22] refactor(routes): rename to workspace + consolidate URL spellings (Phase 2 Wave E) Hard cutover of the HTTP surface: collapse the three legacy spellings (/searchspaces, /search-spaces, /search-space/{id}) onto canonical /workspaces/{workspace_id}/..., rename the gateway field-setter sub-actions to singular /workspace, rename search_spaces_routes.py -> workspaces_routes.py and the search_spaces_router -> workspaces_router include, and flip search_space_id -> workspace_id in bodies/params. No alias routers: the old URLs now 404 by design. Full app constructs with zero legacy path spellings. --- surfsense_backend/app/routes/__init__.py | 8 +- .../app/routes/agent_action_log_route.py | 8 +- .../app/routes/agent_permissions_route.py | 56 +-- .../routes/airtable_add_connector_route.py | 4 +- .../app/routes/chat_comments_routes.py | 4 +- .../app/routes/circleback_webhook_route.py | 36 +- .../app/routes/clickup_add_connector_route.py | 8 +- .../app/routes/composio_routes.py | 16 +- .../routes/confluence_add_connector_route.py | 8 +- .../app/routes/discord_add_connector_route.py | 8 +- .../app/routes/documents_routes.py | 250 ++++++------ .../app/routes/dropbox_add_connector_route.py | 10 +- surfsense_backend/app/routes/editor_routes.py | 40 +- surfsense_backend/app/routes/export_routes.py | 10 +- .../app/routes/folders_routes.py | 76 ++-- .../app/routes/gateway_webhook_routes.py | 76 ++-- .../routes/gateway_whatsapp_baileys_routes.py | 10 +- .../google_calendar_add_connector_route.py | 6 +- .../google_drive_add_connector_route.py | 14 +- .../google_gmail_add_connector_route.py | 8 +- .../app/routes/image_generation_routes.py | 82 ++-- .../app/routes/jira_add_connector_route.py | 8 +- .../app/routes/linear_add_connector_route.py | 8 +- surfsense_backend/app/routes/logs_routes.py | 70 ++-- .../app/routes/luma_add_connector_route.py | 22 +- .../app/routes/mcp_oauth_route.py | 6 +- .../app/routes/model_connections_routes.py | 206 +++++----- .../app/routes/new_chat_routes.py | 204 +++++----- surfsense_backend/app/routes/notes_routes.py | 38 +- .../app/routes/notion_add_connector_route.py | 8 +- .../app/routes/oauth_connector_base.py | 6 +- .../app/routes/obsidian_plugin_routes.py | 44 +-- .../routes/onedrive_add_connector_route.py | 10 +- .../app/routes/prompts_routes.py | 20 +- surfsense_backend/app/routes/rbac_routes.py | 366 +++++++++--------- .../app/routes/reports_routes.py | 34 +- .../app/routes/sandbox_routes.py | 2 +- .../routes/search_source_connectors_routes.py | 342 ++++++++-------- .../app/routes/slack_add_connector_route.py | 8 +- surfsense_backend/app/routes/stripe_routes.py | 12 +- .../app/routes/team_memory_routes.py | 28 +- .../app/routes/teams_add_connector_route.py | 4 +- .../app/routes/video_presentations_routes.py | 36 +- ..._spaces_routes.py => workspaces_routes.py} | 274 ++++++------- 44 files changed, 1247 insertions(+), 1247 deletions(-) rename surfsense_backend/app/routes/{search_spaces_routes.py => workspaces_routes.py} (54%) 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) From ca9bd28934bb1518de290945a9de2e7c05dda646 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 18:36:46 +0200 Subject: [PATCH 19/22] test: rename SearchSpace -> Workspace across tests + fixtures (Phase 2 Wave F) Apply the same rename to surfsense_backend/tests: workspace_id fields/vars, Workspace* classes/schemas, table name searchspaces -> workspaces in raw SQL, and the API URL spellings -> /workspaces. Preserves the carve-out wire literals tests assert (Celery task names, OTel key "search_space.id"). --- surfsense_backend/tests/conftest.py | 4 +- .../shared/retrieval/test_hybrid_search.py | 58 +++--- .../tools/test_search_knowledge_base.py | 78 ++++---- .../multi_agent_chat/test_agent_turn.py | 18 +- .../test_kb_filesystem_cloud.py | 6 +- .../test_kb_filesystem_desktop.py | 2 +- .../chat/test_append_message_recovery.py | 28 +-- .../integration/chat/test_message_id_sse.py | 6 +- .../integration/chat/test_persistence.py | 42 ++--- .../chat/test_thread_visibility.py | 58 +++--- .../tests/integration/composio/conftest.py | 4 +- .../composio/test_oauth_callback.py | 24 +-- .../tests/integration/conftest.py | 16 +- .../integration/document_upload/conftest.py | 20 +-- .../document_upload/test_document_upload.py | 60 +++---- .../document_upload/test_etl_credits.py | 50 +++--- .../test_stripe_credit_purchases.py | 24 +-- .../document_upload/test_upload_limits.py | 12 +- .../google_unification/conftest.py | 44 ++--- .../test_calendar_indexer_credentials.py | 12 +- .../test_drive_indexer_credentials.py | 12 +- .../test_gmail_indexer_credentials.py | 12 +- .../test_hybrid_search_type_filtering.py | 12 +- .../test_search_includes_legacy_docs.py | 12 +- .../adapters/test_file_upload_adapter.py | 62 +++---- .../test_calendar_pipeline.py | 20 +-- .../indexing_pipeline/test_drive_pipeline.py | 32 ++-- .../test_dropbox_pipeline.py | 22 +-- .../indexing_pipeline/test_gmail_pipeline.py | 20 +-- .../indexing_pipeline/test_index_batch.py | 10 +- .../indexing_pipeline/test_index_document.py | 46 ++--- .../indexing_pipeline/test_index_editions.py | 24 +-- .../test_local_folder_pipeline.py | 168 +++++++++--------- .../test_mark_connector_documents_failed.py | 24 +-- .../test_migrate_legacy_docs.py | 18 +- .../test_onedrive_pipeline.py | 22 +-- .../test_prepare_for_indexing.py | 94 +++++----- .../notifications/test_base_handler.py | 36 ++-- .../test_comment_reply_handler.py | 20 +-- .../test_connector_indexing_handler.py | 42 ++--- .../test_document_processing_handler.py | 26 +-- .../notifications/test_inbox_api.py | 4 +- .../test_insufficient_credits_handler.py | 12 +- .../notifications/test_mention_handler.py | 20 +-- .../tests/integration/podcasts/conftest.py | 14 +- .../integration/podcasts/test_brief_gate.py | 22 +-- .../tests/integration/podcasts/test_cancel.py | 12 +- .../tests/integration/podcasts/test_create.py | 8 +- .../integration/podcasts/test_draft_task.py | 22 +-- .../podcasts/test_public_stream.py | 16 +- .../integration/podcasts/test_regeneration.py | 46 ++--- .../integration/podcasts/test_render_task.py | 12 +- .../integration/podcasts/test_scoping.py | 14 +- .../integration/podcasts/test_streaming.py | 12 +- .../integration/podcasts/test_task_failure.py | 8 +- .../tests/integration/retriever/conftest.py | 28 +-- .../test_optimized_chunk_retriever.py | 20 +-- .../retriever/test_optimized_doc_retriever.py | 12 +- .../integration/test_connector_index_authz.py | 42 ++--- .../integration/test_document_versioning.py | 6 +- .../test_obsidian_plugin_routes.py | 38 ++-- .../integration/test_pat_fail_closed_authz.py | 20 +-- .../integration/test_zero_authz_context.py | 28 +-- .../unit/agents/new_chat/test_action_log.py | 26 +-- .../agents/new_chat/test_mention_resolver.py | 12 +- .../agents/new_chat/test_path_resolver.py | 10 +- .../agents/new_chat/test_plugin_loader.py | 6 +- .../agents/new_chat/test_skills_backends.py | 12 +- .../new_chat/tools/test_resume_page_limits.py | 8 +- .../builtin/agent_task/test_dependencies.py | 56 +++--- .../automations/runtime/test_execute_step.py | 2 +- .../runtime/test_executor_action_ctx.py | 6 +- .../schemas/api/test_api_automation.py | 10 +- .../test_automation_service_policy.py | 62 +++---- .../automations/services/test_model_policy.py | 28 +-- .../automations/templating/test_context.py | 4 +- .../triggers/builtin/event/test_filter.py | 14 +- .../triggers/builtin/event/test_inputs.py | 2 +- .../triggers/builtin/event/test_match.py | 2 +- .../test_confluence_parallel.py | 6 +- .../test_dropbox_parallel.py | 22 +-- .../connector_indexers/test_etl_credits.py | 6 +- .../test_google_drive_parallel.py | 20 +-- .../test_linear_parallel.py | 6 +- .../test_notion_parallel.py | 6 +- .../test_onedrive_parallel.py | 12 +- .../tests/unit/event_bus/test_bus.py | 10 +- .../test_entered_folder_predicate.py | 4 +- .../tests/unit/event_bus/test_event.py | 12 +- .../tests/unit/gateway/test_webhook_routes.py | 4 +- .../test_connector_document.py | 20 +-- .../test_create_placeholder_documents.py | 6 +- .../test_document_hashing.py | 22 +-- .../indexing_pipeline/test_index_batch.py | 6 +- .../test_index_batch_parallel.py | 8 +- .../test_migrate_legacy_docs.py | 8 +- .../test_prepare_placeholder_dedup.py | 2 +- .../test_b_filesystem_rm_rmdir_cloud.py | 6 +- .../middleware/test_filesystem_backends.py | 8 +- .../test_kb_persistence_filesystem_parity.py | 10 +- .../test_kb_persistence_revisions.py | 6 +- .../unit/middleware/test_kb_postgres_read.py | 2 +- .../unit/middleware/test_knowledge_tree.py | 2 +- .../unit/notifications/api/test_transform.py | 2 +- .../messages/test_document_processing.py | 2 +- .../messages/test_insufficient_credits.py | 4 +- .../tests/unit/observability/test_otel.py | 4 +- .../unit/observability/test_retriever_otel.py | 6 +- .../tests/unit/podcasts/test_api_schemas.py | 2 +- .../tests/unit/routes/test_image_gen_quota.py | 22 +-- .../routes/test_regenerate_from_message_id.py | 6 +- .../services/test_agent_billing_resolver.py | 90 +++++----- .../unit/services/test_ai_sort_task_dedupe.py | 6 +- .../services/test_auto_model_pin_service.py | 44 ++--- .../services/test_auto_pin_image_aware.py | 14 +- .../tests/unit/services/test_billable_call.py | 22 +-- .../test_image_gen_api_base_defense.py | 16 +- .../services/test_quota_checked_vision_llm.py | 8 +- .../services/test_revert_filesystem_tools.py | 6 +- .../tasks/chat/streaming/test_llm_bundle.py | 14 +- .../tasks/test_video_presentation_billing.py | 36 ++-- .../tests/unit/test_pat_fail_closed_static.py | 6 +- .../tests/unit/utils/test_validators.py | 10 +- surfsense_backend/tests/utils/helpers.py | 28 +-- 124 files changed, 1269 insertions(+), 1269 deletions(-) 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 f894114d6..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 workspace_id = $1", - search_space_id, + 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 e096b30d6..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,7 +195,7 @@ 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( @@ -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,13 +297,13 @@ 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 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 576255fe0..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) @@ -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", From 902b3374eabc694b3b42eca9ea3bacd88319c19a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 26 Jun 2026 18:38:11 +0200 Subject: [PATCH 20/22] docs(env): update spawn-paused runbook to workspace_id (Phase 2 carve-outs) Align the .env.example operational runbook with the renamed Redis key surfsense:spawn_paused: (carve-out 3). All other carve-out decisions are applied inline: KEEP enum values 'SEARCH_SPACE', Celery task names, OTel/metric key "search_space.id", and historical alembic migrations; RENAME the Redis/event/LangGraph keys and the default seed name "My Workspace". --- surfsense_backend/.env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 2acdf42661d7f102022ca7b7dd453bc9b948f008 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 27 Jun 2026 22:24:41 +0200 Subject: [PATCH 21/22] docs(plans): record as-built state + re-runnable verification for Phase 1 (DB rename) Add a top status banner (SHIPPED, branch/PR, phase tip commit) and an Implementation record (deviations, carve-outs as-shipped, re-runnable verification gates with last results) to 01-rename-db.md. --- plans/backend/01-rename-db.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) 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). From 7a38614e07109d78aa90fe7d341b80b9b57286d4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 27 Jun 2026 22:24:41 +0200 Subject: [PATCH 22/22] docs(plans): record as-built state + re-runnable verification for Phase 2 (backend rename) Add a top status banner (SHIPPED, branch/PR, phase tip commit) and an Implementation record (deviations, carve-outs as-shipped, re-runnable verification gates, deploy caveats) to 02-rename-backend.md. --- plans/backend/02-rename-backend.md | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) 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.